commit 52ae0c58025e6f179e6bf8b9642565d747b39ff0 Author: upstream-mirror Date: Tue Sep 15 21:41:59 2026 +0500 snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted) diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..c7fdf45 --- /dev/null +++ b/.air.toml @@ -0,0 +1,26 @@ +root = "." +tmp_dir = ".air" + +[build] +pre_cmd = ["killall -9 gitea 2>/dev/null || true"] # kill off potential zombie processes from previous runs +cmd = "make --no-print-directory backend" +entrypoint = ["./gitea"] +delay = 2000 +include_ext = ["go", "tmpl"] +include_file = ["main.go"] +include_dir = ["cmd", "models", "modules", "options", "routers", "services"] +exclude_dir = [ + "models/fixtures", + "modelmigration/fixtures", + "modules/avatar/identicon/testdata", + "modules/avatar/testdata", + "modules/git/tests", + "modules/migration/file_format_testdata", + "routers/private/tests", + "services/gitdiff/testdata", +] +exclude_regex = ["_test.go$", "_gen.go$"] +stop_on_error = true + +[log] +main_only = true diff --git a/.changelog.yml b/.changelog.yml new file mode 100644 index 0000000..7486765 --- /dev/null +++ b/.changelog.yml @@ -0,0 +1,59 @@ +# The full repository name +repo: go-gitea/gitea + +# Service type (gitea or github) +service: github + +# Base URL for Gitea instance if using gitea service type (optional) +# Default: https://gitea.com +base-url: + +# Changelog groups and which labeled PRs to add to each group +groups: + - + name: BREAKING + labels: + - pr/breaking + - + name: SECURITY + labels: + - topic/security + - + name: FEATURES + labels: + - type/feature + - + name: ENHANCEMENTS + labels: + - type/enhancement + - + name: PERFORMANCE + labels: + - performance/memory + - performance/speed + - performance/bigrepo + - performance/cpu + - + name: BUGFIXES + labels: + - type/bug + + - + name: TESTING + labels: + - type/testing + - + name: BUILD + labels: + - topic/build + - topic/code-linting + - + name: DOCS + labels: + - type/docs + - + name: MISC + default: true + +# regex indicating which labels to skip for the changelog +skip-labels: skip-changelog|backport\/.+ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4863391 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +{ + "name": "Gitea DevContainer", + "image": "mcr.microsoft.com/devcontainers/go:1.26-trixie", + "containerEnv": { + // override "local" from packaged version + "GOTOOLCHAIN": "auto" + }, + "features": { + // installs nodejs into container + "ghcr.io/devcontainers/features/node:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/git-lfs:1.2.5": {}, + "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.14" + }, + "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} + }, + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "golang.go", + "stylelint.vscode-stylelint", + "DavidAnson.vscode-markdownlint", + "Vue.volar", + "ms-azuretools.vscode-docker", + "vitest.explorer", + "cweijan.vscode-database-client2", + "GitHub.vscode-pull-request-github", + "Azurite.azurite" + ] + } + }, + "portsAttributes": { + "3000": { + "label": "Gitea Web", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "make deps" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f61d8ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,90 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# IntelliJ +.idea +# Goland's output filename can not be set manually +/go_build_* + +# MS VSCode +.vscode +__debug_bin* + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +*coverage.out +coverage.all +cpu.out + +*.db +*.log + +/gitea +/debug + +/bin +/dist +/custom/* +!/custom/conf +/custom/conf/* +!/custom/conf/app.example.ini +/data +/indexers +/log +/tests/integration/gitea-integration-* +/tests/*.ini +/node_modules +/yarn.lock +/yarn-error.log +/npm-debug.log* +/pnpm-debug.log* +/public/assets/js +/public/assets/css +/public/assets/fonts +/public/assets/img/avatar +/vendor +/VERSION +/.air +/.go-licenses +/Dockerfile +/Dockerfile.rootless +/.venv + +# Files and folders that were previously generated +/public/assets/img/webpack + +# Snapcraft +snap/.snapcraft/ +parts/ +stage/ +prime/ +*.snap +*.snap-build +*_source.tar.bz2 +.DS_Store + +# Make evidence files +/.make_evidence + +# Manpage +/man diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..85b9cb9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +tab_width = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{go,tmpl,html}] +indent_style = tab + +[go.*] +indent_style = tab + +[templates/custom/*.tmpl] +insert_final_newline = false + +[templates/swagger/*.generated.json] +indent_style = space +insert_final_newline = false + +[templates/shared/actions/runner_badge_*.tmpl] +# editconfig lint requires these XML-like files to have charset defined, but the files don't have. +charset = unset + +[Makefile] +indent_style = tab + +[*.svg] +insert_final_newline = false diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f1795e4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.tmpl linguist-language=Handlebars +*.pb.go linguist-generated +/assets/*.json linguist-generated +/public/assets/img/svg/*.svg linguist-generated +/templates/swagger/*.generated.json linguist-generated +/options/fileicon/** linguist-generated +/vendor/** -text -eol linguist-vendored +Dockerfile.* linguist-language=Dockerfile +Makefile.* linguist-language=Makefile diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1447a6e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +open_collective: gitea diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml new file mode 100644 index 0000000..e793e06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -0,0 +1,28 @@ +name: Bug Report +description: Something isn't working as expected. +labels: ["type/bug"] +body: + - type: markdown + attributes: + value: | + - **Security issue?** Email security@gitea.io instead of opening a public issue. + - **Need help** with setup or configuration? Ask on [Discord](https://discord.gg/Gitea) or the [forum](https://forum.gitea.com). + - Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first. + - type: input + id: gitea-ver + attributes: + label: Gitea Version + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: What you did, what you expected to happen, and what happened instead. Include logs if relevant. + validations: + required: true + - type: textarea + id: environment + attributes: + label: How are you running Gitea? + description: Install method (binary, Docker, package), operating system, and database. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d409f18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Security Concern + url: https://tinyurl.com/security-gitea + about: For security concerns, please send a mail to security@gitea.io instead of opening a public issue. + - name: Discord Server + url: https://discord.gg/Gitea + about: Please ask questions and discuss configuration or deployment problems here. + - name: Discourse Forum + url: https://forum.gitea.com + about: Questions and configuration or deployment problems can also be discussed on our forum. + - name: Frequently Asked Questions + url: https://docs.gitea.com/help/faq + about: Please check if your question isn't mentioned here. + - name: Crowdin Translations + url: https://translate.gitea.com + about: Translations are managed here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 0000000..3ad0fb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,20 @@ +name: Feature Request +description: Suggest an idea for Gitea. +labels: ["type/proposal"] +body: + - type: markdown + attributes: + value: | + Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first. + - type: textarea + id: problem + attributes: + label: What problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What do you propose? + validations: + required: true diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..2adea23 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +self-hosted-runner: + labels: + - actuated-4cpu-8gb + - actuated-4cpu-16gb + - nscloud + - namespace-profile-gitea-release-docker + - namespace-profile-gitea-release-binary diff --git a/.github/actions/docker-dryrun/action.yml b/.github/actions/docker-dryrun/action.yml new file mode 100644 index 0000000..08056d9 --- /dev/null +++ b/.github/actions/docker-dryrun/action.yml @@ -0,0 +1,31 @@ +name: docker-dryrun +description: Composite action that performs the container build steps for a single platform. + +inputs: + platform: + description: "The target platform: linux/amd64, linux/arm64, linux/riscv64." + required: true + +runs: + using: composite + steps: + - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + with: + cache-image: false + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - name: Build regular image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ inputs.platform }} + push: false + file: Dockerfile + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful + - name: Build rootless image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ inputs.platform }} + push: false + file: Dockerfile.rootless + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless diff --git a/.github/actions/free-disk-space/action.yml b/.github/actions/free-disk-space/action.yml new file mode 100644 index 0000000..c3b7e45 --- /dev/null +++ b/.github/actions/free-disk-space/action.yml @@ -0,0 +1,22 @@ +name: free-disk-space +description: Free space on / before large cache restores + +# Delete preinstalled toolchains which gitea doesn't use and show disk space usage +runs: + using: composite + steps: + - shell: bash + run: | + avail=$(df --output=avail -BG / | tr -dc '0-9') + if [ "$avail" -ge 50 ]; then + echo "skipping cleanup, ${avail}G free" + exit 0 + fi + echo "free space before cleanup:" + df -h / + for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do + sudo rm -rf "$dir" & + done + wait + echo "free space after cleanup:" + df -h / diff --git a/.github/actions/go-cache/action.yml b/.github/actions/go-cache/action.yml new file mode 100644 index 0000000..347cd0a --- /dev/null +++ b/.github/actions/go-cache/action.yml @@ -0,0 +1,56 @@ +name: go-caches +description: Restore the go module, build, and golangci-lint caches. Save only on the cache-seeder workflow. + +# Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml. +# The lint job restores but does not save the gobuild and gomod caches, so every key +# has a single writer and there is no contention on it. +# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean +# cache and size stays bounded; do not add restore-keys here. PR runs keep them. +# The go version is part of the gobuild and golint keys because a toolchain bump +# invalidates every entry in them, so a cross-version fallback would restore +# gigabytes that no build can use. The module cache is version-independent. + +inputs: + go-version: + description: Go version the build caches belong to + required: true + lint-cache: + description: Restore (and save in cache-seeder) ~/.cache/golangci-lint + default: "false" + +runs: + using: composite + steps: + - if: ${{ github.workflow == 'cache-seeder' && inputs.lint-cache != 'true' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/go/pkg/mod + key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + - if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/go/pkg/mod + key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + restore-keys: gomod-${{ runner.os }}-${{ runner.arch }}- + - if: ${{ github.workflow == 'cache-seeder' && inputs.lint-cache != 'true' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/go-build + key: gobuild-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}-${{ hashFiles('go.sum') }} + - if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/go-build + key: gobuild-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}-${{ hashFiles('go.sum') }} + restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}- + - if: ${{ inputs.lint-cache == 'true' && github.workflow == 'cache-seeder' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/golangci-lint + key: golint-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}-${{ hashFiles('go.sum', '.golangci.yml') }} + - if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/golangci-lint + key: golint-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}-${{ hashFiles('go.sum', '.golangci.yml') }} + restore-keys: golint-${{ runner.os }}-${{ runner.arch }}-go${{ inputs.go-version }}- diff --git a/.github/actions/go-setup/action.yml b/.github/actions/go-setup/action.yml new file mode 100644 index 0000000..108f340 --- /dev/null +++ b/.github/actions/go-setup/action.yml @@ -0,0 +1,26 @@ +name: go-setup +description: Set up go and restore caches + +inputs: + cache: + description: Restore go caches + default: "true" + lint-cache: + description: Also restore the golangci-lint cache + default: "false" + +runs: + using: composite + steps: + - uses: ./.github/actions/free-disk-space + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + id: setup-go + with: + go-version-file: go.mod + check-latest: true + cache: false + - if: ${{ inputs.cache == 'true' }} + uses: ./.github/actions/go-cache + with: + go-version: ${{ steps.setup-go.outputs.go-version }} + lint-cache: ${{ inputs.lint-cache }} diff --git a/.github/actions/node-setup/action.yml b/.github/actions/node-setup/action.yml new file mode 100644 index 0000000..8f81b93 --- /dev/null +++ b/.github/actions/node-setup/action.yml @@ -0,0 +1,30 @@ +name: node-setup +description: Set up pnpm and node and restore the pnpm store cache + +inputs: + cache: + description: Restore the pnpm store cache + default: "true" + +runs: + using: composite + steps: + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 26 + - if: ${{ inputs.cache == 'true' }} + id: store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - if: ${{ inputs.cache == 'true' && github.workflow == 'cache-seeder' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + - if: ${{ inputs.cache == 'true' && github.workflow != 'cache-seeder' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}-${{ runner.arch }}- diff --git a/.github/actions/pgsql-shard/action.yml b/.github/actions/pgsql-shard/action.yml new file mode 100644 index 0000000..9a5a03a --- /dev/null +++ b/.github/actions/pgsql-shard/action.yml @@ -0,0 +1,40 @@ +name: pgsql-shard +description: Run one pgsql integration test shard + +inputs: + shard: + description: Shard index + required: true + total-shards: + description: Total shard count + required: true + run-migration: + description: Also run migration tests + default: "false" + +runs: + using: composite + steps: + - name: Add hosts to /etc/hosts + shell: bash + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts' + - shell: bash + run: make deps-backend + - shell: bash + run: make backend + env: + TAGS: bindata + - name: run migration tests + if: ${{ inputs.run-migration == 'true' }} + shell: bash + run: GITEA_TEST_DATABASE=pgsql make test-migration + - name: run tests + shell: bash + run: GITEA_TEST_DATABASE=pgsql make test-integration + env: + # pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower. + GOTEST_FLAGS: -race -timeout=40m + TAGS: bindata gogit + TEST_LDAP: 1 + TEST_SHARD: ${{ inputs.shard }} + TEST_TOTAL_SHARDS: ${{ inputs.total-shards }} diff --git a/.github/actions/python-setup/action.yml b/.github/actions/python-setup/action.yml new file mode 100644 index 0000000..52488fa --- /dev/null +++ b/.github/actions/python-setup/action.yml @@ -0,0 +1,10 @@ +name: python-setup +description: Set up uv and python and restore the uv cache + +runs: + using: composite + steps: + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: 3.14 + save-cache: ${{ github.workflow == 'cache-seeder' }} diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..937e69e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,14 @@ +docs-update-needed: + - changed-files: + - any-glob-to-any-file: + - "custom/conf/app.example.ini" + +topic/code-linting: + - changed-files: + - any-glob-to-any-file: + - ".golangci.yml" + - ".markdownlint.yaml" + - ".spectral.yaml" + - ".yamllint.yaml" + - "eslint*.config.*" + - "stylelint.config.*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6ae22da --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ + diff --git a/.github/workflows/cache-prune.yml b/.github/workflows/cache-prune.yml new file mode 100644 index 0000000..b0affe3 --- /dev/null +++ b/.github/workflows/cache-prune.yml @@ -0,0 +1,48 @@ +name: cache-prune + +# Keeps the repository's total cache size below a fixed limit, so that GitHub never +# rejects a save for being over the allowance. + +on: + schedule: + - cron: "37 */6 * * *" # every six hours at :37 + workflow_dispatch: + workflow_call: + +permissions: {} + +concurrency: + group: cache-prune + +jobs: + prune: + runs-on: ubuntu-latest + if: github.repository == 'go-gitea/gitea' + permissions: + actions: write # to delete caches + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + # Keep the newest generation per key, restores never reach the older ones. + - name: delete superseded caches + run: | + gh cache list --limit 1000 --json id,key,ref,createdAt | + jq -r 'group_by([.ref, (.key | sub("(-go[0-9.]+)?-[0-9a-f]{40,64}(-[0-9]+-[0-9]+)?$"; ""))])[] + | sort_by(.createdAt)[:-1][] | "\(.id) \(.key)"' | + while read -r id key; do + echo "deleting $key" + gh cache delete "$id" || true + done + + # Deletes least recently used first, the order GitHub itself evicts in. + - name: delete caches over the size limit + run: | + caches=$(gh cache list --limit 1000 --sort last_accessed_at --order asc --json id,key,sizeInBytes) + size=$(jq '[.[].sizeInBytes] | add // 0' <<< "$caches") + echo "cache usage: $((size / 1000000)) MB" + while [ "$size" -gt 6000000000 ] && read -r id bytes key; do + echo "deleting $key" + gh cache delete "$id" || true + size=$((size - bytes)) + done <<< "$(jq -r '.[] | "\(.id) \(.sizeInBytes) \(.key)"' <<< "$caches")" diff --git a/.github/workflows/cache-seeder.yml b/.github/workflows/cache-seeder.yml new file mode 100644 index 0000000..4a597b6 --- /dev/null +++ b/.github/workflows/cache-seeder.yml @@ -0,0 +1,97 @@ +# Populates main's cache scope so PR runs warm-start from it. Caches are ref-scoped: +# PR runs read their own scope then fall back to the base branch, and only this +# workflow saves, so push-to-main is the only chance to populate the fallback scope. + +name: cache-seeder + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "go.mod" # a toolchain bump invalidates the build caches + - "go.sum" + - ".golangci.yml" + - "pnpm-lock.yaml" + - "pyproject.toml" + - "uv.lock" + - ".github/actions/go-cache/action.yml" + - ".github/actions/go-setup/action.yml" + - ".github/actions/node-setup/action.yml" + - ".github/actions/python-setup/action.yml" + - ".github/workflows/cache-seeder.yml" + +concurrency: + group: cache-seeder + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gobuild: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - run: make deps-backend deps-tools + - run: TAGS="bindata" make backend + - run: TAGS="bindata gogit" make backend + - name: warm test compile cache (bindata) + env: + TAGS: bindata + GOTEST_FLAGS: -race -list=^$$ -count=1 + run: make test-backend + - name: warm test compile cache (bindata gogit) + env: + TAGS: bindata gogit + GOTEST_FLAGS: -race -list=^$$ -count=1 + run: make test-backend + - name: warm integration compile cache + run: | + TAGS="bindata" make test-integration-compile + TAGS="bindata gogit" make test-integration-compile + TAGS="bindata gogit" GOTEST_FLAGS="-race" make test-integration-compile + + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { tags: "bindata", target: "lint-backend" } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + with: + lint-cache: "true" + - run: make deps-backend deps-tools + - run: make generate-go + env: + TAGS: ${{ matrix.tags }} + - run: make "$TARGET" + env: + TAGS: ${{ matrix.tags }} + TARGET: ${{ matrix.target }} + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/node-setup + - run: make deps-frontend + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/python-setup + - run: make deps-py + + # reclaims the caches this run superseded, so the next save still fits in the allowance + prune: + needs: [gobuild, lint, frontend, python] + permissions: + actions: write + uses: ./.github/workflows/cache-prune.yml diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml new file mode 100644 index 0000000..4022778 --- /dev/null +++ b/.github/workflows/cron-licenses.yml @@ -0,0 +1,34 @@ +name: cron-licenses + +on: + # schedule: + # - cron: "7 0 * * 1" # every Monday at 00:07 UTC + workflow_dispatch: + +permissions: {} + +jobs: + cron-licenses: + runs-on: ubuntu-latest + if: github.repository == 'go-gitea/gitea' + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + check-latest: true + cache: false + - run: make generate-gitignore + timeout-minutes: 40 + - name: push translations to repo + uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0 + with: + author_email: "teabot@gitea.io" + author_name: GiteaBot + branch: main + commit: true + commit_message: "[skip ci] Updated licenses and gitignores" + remote: "git@github.com:go-gitea/gitea.git" + ssh_key: ${{ secrets.DEPLOY_KEY }} diff --git a/.github/workflows/cron-renovate.yml b/.github/workflows/cron-renovate.yml new file mode 100644 index 0000000..1da0612 --- /dev/null +++ b/.github/workflows/cron-renovate.yml @@ -0,0 +1,32 @@ +name: cron-renovate + +on: + schedule: + - cron: "23 * * * *" # hourly at :23 + workflow_dispatch: + +concurrency: + group: cron-renovate + +env: + RENOVATE_VERSION: 44.71.1 # renovate: datasource=npm depName=renovate + +permissions: + contents: read + +jobs: + cron-renovate: + runs-on: ubuntu-latest + if: github.repository == 'go-gitea/gitea' # prevent running on forks + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: renovatebot/github-action@37beffda261423addd537c33f2d126df7f6ffbab # v46.2.6 + with: + renovate-version: ${{ env.RENOVATE_VERSION }} + configurationFile: renovate.json5 + token: ${{ secrets.RENOVATE_TOKEN }} + env: + RENOVATE_BINARY_SOURCE: install # auto-install go/node toolchains needed by post-upgrade tasks. + RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg|generate-codemirror-languages)$"]' + RENOVATE_REPOSITORIES: '["go-gitea/gitea"]' diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml new file mode 100644 index 0000000..098e974 --- /dev/null +++ b/.github/workflows/cron-translations.yml @@ -0,0 +1,42 @@ +name: cron-translations + +on: + schedule: + - cron: "7 0 * * *" # every day at 00:07 UTC + workflow_dispatch: + +permissions: {} + +jobs: + crowdin-pull: + runs-on: ubuntu-latest + if: github.repository == 'go-gitea/gitea' + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2 + with: + upload_sources: true + upload_translations: false + download_sources: false + download_translations: true + push_translations: false + push_sources: false + create_pull_request: false + config: crowdin.yml + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_KEY: ${{ secrets.CROWDIN_KEY }} + - name: update locales + run: ./build/update-locales.sh + - name: push translations to repo + uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0 + with: + author_email: "teabot@gitea.io" + author_name: GiteaBot + branch: main + commit: true + commit_message: "[skip ci] Updated translations via Crowdin" + remote: "git@github.com:go-gitea/gitea.git" + ssh_key: ${{ secrets.DEPLOY_KEY }} diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml new file mode 100644 index 0000000..697ab16 --- /dev/null +++ b/.github/workflows/files-changed.yml @@ -0,0 +1,143 @@ +name: files-changed + +on: + workflow_call: + outputs: + backend: + value: ${{ jobs.detect.outputs.backend }} + frontend: + value: ${{ jobs.detect.outputs.frontend }} + docs: + value: ${{ jobs.detect.outputs.docs }} + actions: + value: ${{ jobs.detect.outputs.actions }} + templates: + value: ${{ jobs.detect.outputs.templates }} + docker: + value: ${{ jobs.detect.outputs.docker }} + dockerfile: + value: ${{ jobs.detect.outputs.dockerfile }} + swagger: + value: ${{ jobs.detect.outputs.swagger }} + yaml: + value: ${{ jobs.detect.outputs.yaml }} + json: + value: ${{ jobs.detect.outputs.json }} + e2e: + value: ${{ jobs.detect.outputs.e2e }} + shell: + value: ${{ jobs.detect.outputs.shell }} + +permissions: + contents: read + +jobs: + detect: + runs-on: ubuntu-latest + timeout-minutes: 3 + outputs: + backend: ${{ steps.changes.outputs.backend }} + frontend: ${{ steps.changes.outputs.frontend }} + docs: ${{ steps.changes.outputs.docs }} + actions: ${{ steps.changes.outputs.actions }} + templates: ${{ steps.changes.outputs.templates }} + docker: ${{ steps.changes.outputs.docker }} + dockerfile: ${{ steps.changes.outputs.dockerfile }} + swagger: ${{ steps.changes.outputs.swagger }} + yaml: ${{ steps.changes.outputs.yaml }} + json: ${{ steps.changes.outputs.json }} + e2e: ${{ steps.changes.outputs.e2e }} + shell: ${{ steps.changes.outputs.shell }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: changes + with: + filters: | + backend: + - "**/*.go" + - "templates/**/*.tmpl" + - "assets/emoji.json" + - "go.mod" + - "go.sum" + - "Makefile" + - ".golangci.yml" + - ".editorconfig" + - "options/locale/locale_en-US.json" + - "models/fixtures/**" + - "tests/*.ini.tmpl" + - "tests/gitea-repositories-meta/**" + - "tests/testdata/**" + - "tools/test-integration.sh" + + frontend: + - "*.ts" + - "web_src/**" + - "tools/**/*.ts" + - "tools/**/*.json" + - "tools/playwright.sh" + - "tsconfig.json" + - "assets/emoji.json" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "Makefile" + + docs: + - "**/*.md" + - ".markdownlint.yaml" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + + actions: + - ".github/workflows/*" + - ".github/actions/**" + - "Makefile" + + templates: + - "tools/lint-templates-*.ts" + - "templates/**/*.tmpl" + - "pyproject.toml" + - "uv.lock" + + docker: + - ".github/workflows/pull-docker-dryrun.yml" + - ".github/actions/docker-dryrun/**" + - "Dockerfile" + - "Dockerfile.rootless" + - "docker/**" + - "Makefile" + + dockerfile: + - "Dockerfile" + - "Dockerfile.rootless" + + swagger: + - "templates/swagger/*.json" + - "Makefile" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".spectral.yaml" + + yaml: + - "**/*.yml" + - "**/*.yaml" + - ".yamllint.yaml" + - "pyproject.toml" + + json: + - "**/*.json" + - "**/*.json5" + - "eslint.json.config.ts" + + e2e: + - "tests/e2e/**" + - "tools/test-e2e.sh" + - "tools/playwright.sh" + - "playwright.config.ts" + + shell: + - "**/*.sh" + - ".shellcheckrc" diff --git a/.github/workflows/giteabot-backport.yml b/.github/workflows/giteabot-backport.yml new file mode 100644 index 0000000..7e05729 --- /dev/null +++ b/.github/workflows/giteabot-backport.yml @@ -0,0 +1,26 @@ +name: giteabot backport + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + giteabot: + if: github.repository == 'go-gitea/gitea' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: go-gitea/giteabot@c214e4be0efc926a2d6ce93b7a3911fe7fa65ab1 # v1.0.6 + with: + github_token: ${{ secrets.GITEABOT_TOKEN }} + gitea_fork: giteabot/gitea + checks: backport diff --git a/.github/workflows/giteabot.yml b/.github/workflows/giteabot.yml new file mode 100644 index 0000000..19da653 --- /dev/null +++ b/.github/workflows/giteabot.yml @@ -0,0 +1,65 @@ +name: giteabot + +on: + # When main advances, rerun merge queue maintenance so the oldest + # reviewed/wait-merge PR can be updated against the new base promptly. + push: + branches: + - main + # pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from + # forks, which the bot needs to write labels, statuses and comments. Safe here + # because the job only runs a pinned action and never checks out PR HEAD. + # These PR lifecycle events drive label maintenance, queue maintenance, and + # explicit bot actions triggered by relevant label changes. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: + - opened + - synchronize + - labeled + - unlabeled + - 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: + types: + - submitted + - edited + - dismissed + # Periodic maintenance is still useful as a backstop for queue cleanup and + # other housekeeping, even though main pushes now trigger it promptly. + schedule: + - cron: "15 3 * * *" + # Allow maintainers to rerun selected checks manually when debugging bot + # behavior without waiting for another repository event. + workflow_dispatch: + inputs: + checks: + description: Comma-separated list of non-backport checks to run + required: false + default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions + +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') }} + cancel-in-progress: false + +jobs: + giteabot: + if: github.repository == 'go-gitea/gitea' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write + 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@c214e4be0efc926a2d6ce93b7a3911fe7fa65ab1 # v1.0.6 + with: + github_token: ${{ secrets.GITEABOT_TOKEN || github.token }} + checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }} diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml new file mode 100644 index 0000000..be133fb --- /dev/null +++ b/.github/workflows/pull-compliance.yml @@ -0,0 +1,109 @@ +name: compliance + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + files-changed: + uses: ./.github/workflows/files-changed.yml + + lint-backend: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + with: + lint-cache: "true" + - run: make deps-backend deps-tools + - run: TAGS="bindata" make generate-go lint-backend # lint-go can lint with "bindata" tags + + lint-on-demand: + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + with: + cache: "false" + - uses: ./.github/actions/node-setup + with: + cache: "false" + + - run: make lint-spell + + - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' || needs.files-changed.outputs.actions == 'true' + uses: ./.github/actions/python-setup + - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' + run: make deps-py lint-templates lint-yaml + + - if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.swagger == 'true' || needs.files-changed.outputs.json == 'true' + run: make deps-frontend lint-md lint-swagger lint-json + + - if: needs.files-changed.outputs.actions == 'true' + run: make lint-actions + + - if: needs.files-changed.outputs.shell == 'true' + run: make lint-shell + + checks-backend: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - run: make deps-backend deps-tools + - run: make --always-make checks-backend # ensure the "go-licenses" make target runs + + frontend: + if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/node-setup + - run: make deps-frontend + - run: make lint-frontend + - run: make checks-frontend + - run: make playwright + - run: make test-frontend + - run: make frontend + + backend: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - run: make deps-backend generate-go + # no frontend build here as backend should be able to build, even without any frontend files + # CGO is not used when cross-compile, so these steps also test if the code is compatible with CGO disabled + - name: build-backend-arm64 + run: go build -ldflags '-s -w' -o gitea-linux-arm64 + env: + GOOS: linux + GOARCH: arm64 + TAGS: bindata gogit + - name: build-backend-windows + run: go build -ldflags '-s -w' -o gitea-windows + env: + GOOS: windows + GOARCH: amd64 + TAGS: bindata gogit + - name: build-backend-386 + run: go build -ldflags '-s -w' -o gitea-linux-386 + env: + GOOS: linux + GOARCH: 386 + - name: binary-sizes + run: ls -l gitea-* diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml new file mode 100644 index 0000000..ff1b9fa --- /dev/null +++ b/.github/workflows/pull-db-tests.yml @@ -0,0 +1,254 @@ +name: db-tests + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + files-changed: + uses: ./.github/workflows/files-changed.yml + + test-pgsql-shard-1: + if: needs.files-changed.outputs.backend == 'true' + needs: files-changed + runs-on: ubuntu-latest + timeout-minutes: 50 + services: + pgsql: + image: postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + ldap: + image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0 + ports: + - "389:389" + - "636:636" + minio: + # as github actions doesn't support "entrypoint", we need to use a non-official image + # that has a custom entrypoint set to "minio server /data" + image: bitnamilegacy/minio:2025.7.23@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 + env: + MINIO_ROOT_USER: 123456 + MINIO_ROOT_PASSWORD: 12345678 + ports: + - "9000:9000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/pgsql-shard + with: + shard: 1 + total-shards: 2 + run-migration: "true" + + test-pgsql-shard-2: + if: needs.files-changed.outputs.backend == 'true' + needs: files-changed + runs-on: ubuntu-latest + timeout-minutes: 50 + services: + pgsql: + image: postgres:14@sha256:156f0b253fd61366d5fc2107ad45955027d5612f695a8436ce20167f3fa79bff + env: + POSTGRES_DB: test + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + ldap: + image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0 + ports: + - "389:389" + - "636:636" + minio: + # as github actions doesn't support "entrypoint", we need to use a non-official image + # that has a custom entrypoint set to "minio server /data" + image: bitnamilegacy/minio:2025.7.23@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 + env: + MINIO_ROOT_USER: 123456 + MINIO_ROOT_PASSWORD: 12345678 + ports: + - "9000:9000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/pgsql-shard + with: + shard: 2 + total-shards: 2 + + test-sqlite: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - run: make deps-backend + - run: make backend + env: + TAGS: bindata gogit + - run: GITEA_TEST_DATABASE=sqlite make test-migration + env: + TAGS: bindata gogit + - name: run tests + run: GITEA_TEST_DATABASE=sqlite make test-integration + timeout-minutes: 50 + env: + # sqlite driver can contain large amount of Golang code, so don't use race detector for it, otherwise, extremely slow + GOTEST_FLAGS: -timeout=40m + TAGS: bindata gogit + + test-unit: + if: needs.files-changed.outputs.backend == 'true' + needs: files-changed + runs-on: ubuntu-latest + services: + elasticsearch: + image: elasticsearch:8.19.15@sha256:8ff844933190136cec601777d633453f4008cc126a652257d5f63cd8e92207ef + env: + discovery.type: single-node + xpack.security.enabled: false + xpack.ml.enabled: false + ingest.geoip.downloader.enabled: false + ES_JAVA_OPTS: "-Xms1g -Xmx1g" + ports: + - "9200:9200" + meilisearch: + image: getmeili/meilisearch:v1@sha256:c94e58ca09662dd6e65e8f1b0fd145767be3da7d5422a863a27b8d2b68e090c9 + env: + MEILI_ENV: development # disable auth + ports: + - "7700:7700" + redis: + image: redis:8@sha256:298e5b3bc566bade82f46ad5511777a4a07a294097ce16ada2f6a42be5239df5 + options: >- # wait until redis has started + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + ports: + - 6379:6379 + minio: + image: bitnamilegacy/minio:2025.7.23@sha256:8935e75fa5d11295c17171e4aa49efe390a1193cd7f12e4d21b92af9ffef09d7 + env: + MINIO_ROOT_USER: 123456 + MINIO_ROOT_PASSWORD: 12345678 + ports: + - "9000:9000" + devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 + image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:f9791d0a0613e93057c06ae31f7ac0c0222f0530590e67e020b0986c66a36fa6 + ports: + - 10000:10000 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - name: Add hosts to /etc/hosts + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts' + - run: make deps-backend + - run: make generate-go + env: + TAGS: bindata + - name: unit-tests + run: make test-backend + env: + GOTEST_FLAGS: -race -timeout=20m + TAGS: bindata + GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} + - name: unit-tests-gogit + run: make test-backend + env: + GOTEST_FLAGS: -race -timeout=20m + TAGS: bindata gogit + GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} + GITEA_TEST_CI_SKIP_EXTERNAL: true + - run: make test-check + + test-mysql: + if: needs.files-changed.outputs.backend == 'true' + needs: files-changed + runs-on: ubuntu-latest + services: + mysql: + # the bitnami mysql image has more options than the official one, it's easier to customize + image: bitnamilegacy/mysql:8.4@sha256:7089d796fc9b4629a628bd445e4afabe607351ee665444c3197bdeaed812ea65 + env: + ALLOW_EMPTY_PASSWORD: true + MYSQL_DATABASE: testgitea + ports: + - "3306:3306" + options: >- + --mount type=tmpfs,destination=/bitnami/mysql/data + elasticsearch: + image: elasticsearch:8.19.15@sha256:8ff844933190136cec601777d633453f4008cc126a652257d5f63cd8e92207ef + env: + discovery.type: single-node + xpack.security.enabled: false + xpack.ml.enabled: false + ingest.geoip.downloader.enabled: false + ES_JAVA_OPTS: "-Xms1g -Xmx1g" + ports: + - "9200:9200" + smtpimap: + image: tabascoterrier/docker-imap-devel:latest@sha256:3fb7cf50b47693e7b80f6f74abea2def4d7386016931d61359864de8a0aba551 + ports: + - "25:25" + - "143:143" + - "587:587" + - "993:993" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - name: Add hosts to /etc/hosts + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts' + - run: make deps-backend + - run: make backend + env: + TAGS: bindata + - run: GITEA_TEST_DATABASE=mysql make test-migration + - name: run tests + run: GITEA_TEST_DATABASE=mysql make test-integration + env: + TAGS: bindata + TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" + + test-mssql: + if: needs.files-changed.outputs.backend == 'true' + needs: files-changed + runs-on: ubuntu-latest + services: + mssql: + image: mcr.microsoft.com/mssql/server:2019-latest@sha256:ef0b8db33970ecd01bed49c3a84a1d083c435a9891718df619298b67b352e74a + env: + ACCEPT_EULA: Y + MSSQL_PID: Standard + SA_PASSWORD: MwantsaSecurePassword1 + ports: + - "1433:1433" + devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 + image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:f9791d0a0613e93057c06ae31f7ac0c0222f0530590e67e020b0986c66a36fa6 + ports: + - 10000:10000 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - name: Add hosts to /etc/hosts + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts' + - run: make deps-backend + - run: make backend + env: + TAGS: bindata + - run: GITEA_TEST_DATABASE=mssql make test-migration + - name: run tests + run: GITEA_TEST_DATABASE=mssql make test-integration + timeout-minutes: 50 + env: + TAGS: bindata diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml new file mode 100644 index 0000000..ce29a15 --- /dev/null +++ b/.github/workflows/pull-docker-dryrun.yml @@ -0,0 +1,47 @@ +name: docker-dryrun + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + files-changed: + uses: ./.github/workflows/files-changed.yml + + # QEMU-based build is slow (40-50 minutes), so run arm64 and riscv64 when dockerfile changes. + # Run amd64 when any docker-related files change, which is fast (4 minutes). + container-amd64: + if: needs.files-changed.outputs.docker == 'true' + needs: [files-changed] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/docker-dryrun + with: + platform: linux/amd64 + + container-arm64: + if: needs.files-changed.outputs.dockerfile == 'true' + needs: [files-changed] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/docker-dryrun + with: + platform: linux/arm64 + + container-riscv64: + if: needs.files-changed.outputs.dockerfile == 'true' + needs: [files-changed] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/docker-dryrun + with: + platform: linux/riscv64 diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml new file mode 100644 index 0000000..0468b9b --- /dev/null +++ b/.github/workflows/pull-e2e-tests.yml @@ -0,0 +1,37 @@ +name: e2e-tests + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + files-changed: + uses: ./.github/workflows/files-changed.yml + + test-e2e: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.e2e == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/node-setup + - run: make deps-frontend + - run: make frontend + - run: make deps-backend + - run: make backend + env: + TAGS: bindata + - run: make playwright + - run: make test-e2e + timeout-minutes: 10 + env: + TAGS: bindata + FORCE_COLOR: 1 + GITEA_TEST_E2E_DEBUG: 1 diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml new file mode 100644 index 0000000..aa4cdff --- /dev/null +++ b/.github/workflows/pull-labeler.yml @@ -0,0 +1,50 @@ +name: labeler + +on: + # pull_request_target is required to label PRs from forks; jobs only use pinned + # actions or base-branch checkout, never PR-head code. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, synchronize, reopened, edited, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: {} + +jobs: + labeler: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7 + with: + sync-labels: true + + pr-title: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + # Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 26 + # Labels are only synced after the title lints, so an invalid title never reaches the label diff. + - run: node ./tools/ci-tools.ts lint-pr-title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + - run: node ./tools/ci-tools.ts set-pr-labels + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_TITLE_BEFORE: ${{ github.event.changes.title.from }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-nightly-snapcraft.yml b/.github/workflows/release-nightly-snapcraft.yml new file mode 100644 index 0000000..eaee386 --- /dev/null +++ b/.github/workflows/release-nightly-snapcraft.yml @@ -0,0 +1,48 @@ +name: release-nightly-snapcraft + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-publish: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Build snap + id: build + env: + SNAPCRAFT_IMAGE_INFO: '{"build_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' + run: | + # snapcraft installs build-snaps and apt packages system-wide, which needs root + sudo snap install --classic snapcraft + sudo SNAPCRAFT_BUILD_INFO=1 SNAPCRAFT_IMAGE_INFO="$SNAPCRAFT_IMAGE_INFO" snapcraft pack --destructive-mode + echo "snap=$(ls ./*.snap)" >> "$GITHUB_OUTPUT" + # retry snapcraft uploads which can be flaky + - name: Publish snap + run: | + for attempt in 1 2 3 4 5; do + snapcraft upload "$SNAP" --release latest/edge && exit 0 + echo "::warning::snap upload attempt $attempt failed, retrying in 15s" + sleep 15 + done + exit 1 + env: + SNAP: ${{ steps.build.outputs.snap }} + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000..aefe7bf --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,144 @@ +name: release-nightly + +on: + push: + branches: [main, release/v*] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + nightly-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\///' -e 's/release\/v//') + echo "Cleaned name is ${REF_NAME}" + echo "branch=${REF_NAME}-nightly" >> "$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 + + nightly-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@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + with: + cache-image: false + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - 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\///' -e 's/release\/v//') + echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" + - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + id: meta + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" + - uses: docker/metadata-action@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: | + suffix=-rootless + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" + - name: Login to Docker Hub + uses: docker/login-action@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 docker image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 + push: true + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful + cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max + - name: build rootless docker image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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 }} + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless + cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless,mode=max diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml new file mode 100644 index 0000000..4fcfed0 --- /dev/null +++ b/.github/workflows/release-tag-rc.yml @@ -0,0 +1,149 @@ +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@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + with: + cache-image: false + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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 }} diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml new file mode 100644 index 0000000..fe90ee1 --- /dev/null +++ b/.github/workflows/release-tag-version.yml @@ -0,0 +1,161 @@ +name: release-tag-version + +on: + push: + tags: + - "v1.*" + - "!v1*-rc*" + - "!v1*-dev" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + binary: + runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read + packages: write # to publish to ghcr.io + 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" --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@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + with: + cache-image: false + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + id: meta + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + # this will generate tags in the following format: + # latest + # 1 + # 1.2 + # 1.2.3 + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + 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: | + suffix=-rootless,onlatest=true + # this will generate tags in the following format (with -rootless suffix added): + # latest + # 1 + # 1.2 + # 1.2.3 + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" + - name: Login to Docker Hub + uses: docker/login-action@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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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 }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..7b82636 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,9 @@ +rules: + anonymous-definition: + disable: true + undocumented-permissions: + disable: true + concurrency-limits: + disable: true + self-repository: + disable: true # actionlint rejects "$/" uses, https://github.com/rhysd/actionlint/issues/711 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76a7578 --- /dev/null +++ b/.gitignore @@ -0,0 +1,122 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# IntelliJ +.idea +.run + +# IntelliJ Gateway +.uuid + +# Goland's output filename can not be set manually +/go_build_* +/gitea_* + +# MS VSCode +.vscode +__debug_bin* + +# Visual Studio +/.vs/ + +# mise version managment tool +mise.toml + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof +*.tsbuildinfo + +*coverage.out +coverage.all +cpu.out + +/modules/migration/bindata.* +/modules/options/bindata.* +/modules/public/bindata.* +/modules/templates/bindata.* + +*.db +*.log +*.log.*.gz + +/gitea +/debug + +/bin +/dist +/custom/* +!/custom/conf/app.example.ini +/data +/indexers +/log +/public/assets/img/avatar +/tests/e2e-output +/tests/integration/gitea-integration-* +/tests/integration/indexers-* +/tests/*.ini +/tests/**/*.git/**/*.sample +/node_modules +/.venv +/yarn.lock +/yarn-error.log +/npm-debug.log* +/.pnpm-store +/public/assets/.vite +/public/assets/js +/public/assets/css +/public/assets/fonts +/public/assets/licenses.txt +/vendor +/VERSION +/.air + + +# Snapcraft +/gitea_a*.txt +snap/.snapcraft/ +parts/ +stage/ +prime/ +*.snap +*.snap-build +*_source.tar.bz2 +.DS_Store + +# nix-direnv generated files +.direnv/ + +# Make evidence files +/.make_evidence + +# Manpage +/man + +# Ignore AI/LLM instruction files +/.claude/ +/.cursorrules +/.cursor/ +/.goosehints +/.windsurfrules +/.github/copilot-instructions.md +/llms.txt + +# Ignore worktrees when working on multiple branches +.worktrees/ + +# A Makefile for custom make targets +Makefile.local diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1a674eb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,201 @@ +version: "2" +output: + sort-order: + - file +linters: + default: none + enable: + - bidichk + - bodyclose + - depguard + - dupl + - errcheck + - forbidigo + - forcetypeassert + - gocheckcompilerdirectives + - gocritic + - govet + - ineffassign + - mirror + - modernize + - nakedret + - nilnil + - nolintlint + - perfsprint + - revive + - staticcheck + - testifylint + - unconvert + - unparam + - unused + - usestdlibvars + - usetesting + - wastedassign + settings: + depguard: + rules: + main: + deny: + - pkg: encoding/json + desc: use gitea's modules/json instead of encoding/json + - pkg: github.com/unknwon/com + desc: use gitea's util and replacements + - pkg: io/ioutil + desc: use os or io instead + - pkg: golang.org/x/exp + desc: it's experimental and unreliable + - pkg: gitea.dev/modules/git/internal + desc: do not use the internal package, use AddXxx function instead + - pkg: gopkg.in/ini.v1 + desc: do not use the ini package, use gitea's config system instead + - pkg: gitea.com/go-chi/cache + desc: do not use the go-chi cache package, use gitea's cache system + - pkg: github.com/pkg/errors + desc: use builtin errors package instead + - pkg: gitea.com/go-chi/binding + desc: use our wrapper + migrations: + files: + - '**/modelmigration/**/*.go' + deny: + - pkg: gitea.dev/models + desc: "migrations must not depend on the models package. HINT: MIGRATION-STRUCT-FROZEN" + - pkg: gitea.dev/modules/structs + desc: "migrations must not depend on modules/structs. HINT: MIGRATION-STRUCT-FROZEN" + forbidigo: + forbid: + - pattern: '^(fmt\.Print(|f|ln)|print|println)$' # default + - pattern: '^exec\.CommandContext$' # use our wrapper for graceful termination + modernize: + disable: + - embedlit + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + gocritic: + enabled-checks: + - equalFold + disabled-checks: + - ifElseChain + - singleCaseSwitch # Every time this occurred in the code, there was no other way. + - deprecatedComment # conflicts with go-swagger comments + revive: + severity: error + rules: + - name: blank-imports + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: empty-lines + - name: error-return + - name: error-strings + - name: exported + - name: identical-branches + - name: if-return + - name: increment-decrement + - name: modifies-value-receiver + - name: package-comments + - name: package-naming + arguments: + - convention-name-check-regex: '^[a-z][a-z0-9_]*$' # underscores are used by migration packages + skip-default-bad-name-check: true + skip-collision-with-common-std: true + - name: redefines-builtin-id + - name: superfluous-else + - name: time-naming + - name: unexported-return + - name: var-declaration + - name: var-naming + arguments: + - [] # AllowList - do not remove as args for the rule are positional and won't work without lists first + - [] # DenyList + - - skip-initialism-name-checks: true + staticcheck: + checks: + - all + - -ST1003 + - -ST1005 + - -QF1001 + - -QF1006 + - -QF1008 + testifylint: + disable: + - empty + - go-require + - require-error + usetesting: + os-temp-dir: true + perfsprint: + concat-loop: false + govet: + enable: + - nilness + - unusedwrite + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - dupl + - errcheck + - forbidigo + - staticcheck + - unparam + path: _test\.go + - linters: + - dupl + path: modelmigration/v + - linters: + - dupl + text: (?i)webhook + - linters: + - gocritic + text: (?i)`ID' should not be capitalized + - linters: + - unused + text: (?i)swagger + - linters: + - gocritic + text: '(?i)commentFormatting: put a space between `//` and comment text' + - linters: + - gocritic + text: '(?i)exitAfterDefer:' + paths: + - ^node_modules/ + - ^\.venv/ + - ^public/ + - ^web_src/ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gci + - gofumpt + settings: + gci: + custom-order: true + sections: + - standard + - prefix(gitea.dev) + - blank + - default + gofumpt: + extra: + group-params: true + exclusions: + generated: lax + paths: + - ^node_modules/ + - ^\.venv/ + - ^public/ + - ^web_src/ + +run: + timeout: 10m diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..29912ad --- /dev/null +++ b/.ignore @@ -0,0 +1,8 @@ +*.min.css +*.min.js +/assets/*.json +/options/gitignore +/options/license +/public/assets +/vendor +node_modules diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..88ff159 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Unknwon +Unknwon 无闻 diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..b251ff7 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,15 @@ +commands-show-output: false +fenced-code-language: false +first-line-h1: false +heading-increment: false +line-length: {code_blocks: false, tables: false, stern: true, line_length: -1} +no-alt-text: false +no-bare-urls: false +no-emphasis-as-heading: false +no-empty-links: false +no-hard-tabs: {code_blocks: false} +no-inline-html: false +no-space-in-code: false +no-space-in-emphasis: false +no-trailing-spaces: {br_spaces: 0} +single-h1: false diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..de3da8e --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +# SC2153: false-alert "Possible misspelling: TAGS may not be assigned. Did you mean tags?". We already use strict mode. +disable=SC1091,SC2001,SC2002,SC2016,SC2028,SC2046,SC2124,SC2128,SC2129,SC2153,SC2154,SC2155,SC2164,SC2181,SC2207 diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..54e5f1c --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,7 @@ +extends: [[spectral:oas, all]] + +rules: + info-contact: off + oas2-api-host: off + oas2-parameter-description: off + operation-description: off diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..1d33ece --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,42 @@ +extends: default + +rules: + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: 0 + max-spaces-inside-empty: 0 + + brackets: + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: 0 + max-spaces-inside-empty: 0 + + comments: + require-starting-space: true + ignore-shebangs: true + min-spaces-from-content: 1 + + comments-indentation: + level: error + + document-start: disable + + document-end: + present: false + + empty-lines: + max: 1 + + indentation: + spaces: 2 + + line-length: disable + + truthy: + allowed-values: ["true", "false", "on", "off"] + +ignore: | + .venv + node_modules diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a7034a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +- Never assume, verify before claiming +- List development targets with `make help` +- Read relevant developer documentation in the `docs` folder +- PR descriptions: minimal, only what and why, no task or file listings. Include screenshots for UI changes, before and after when modifying existing UI. Aim for less than 1000 characters +- Reference issues and PRs by full URL, not by number +- Use Conventional Commits for commit messages and PR titles, plus Gitea's `enhance` type for user-facing enhancements +- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by` +- Attribute agent authorship on one trailing line in issue and PR comments, never as a PR description section +- Never rewrite git history unless asked, update PRs with new commits and normal push +- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply. If you need to write a paragraph-long comment, rethink your implementation, it is likely too complicated +- Add the current year to copyright headers of new `.go` files +- In `options/locale`, only edit `locale_en-US.json`, other locales are synced automatically +- In TS, use `!` instead of `?.`/`??` when a value always exists +- In Go, prefer to use modern language features wherever possible +- Prefer `tw-*` utilities over inline `style` and `flex-*` helpers over per-child `tw-ml-*`/`tw-mr-*` margins, falling back to `tw-*` where specificity requires `!important` +- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, `make generate-swagger` after API changes, and lint what changed with `make lint-go`, `lint-js`, `lint-css` or `lint-templates` +- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason +- Run single tests with `go test -run '^TestName$' ./modulepath/` for Go, `pnpm exec vitest ` for TS and `GITEA_TEST_E2E_FLAGS='' make test-e2e` for e2e +- Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation +- Aim for sub-2s per integration test and sub-4s per e2e test. Wait on a deterministic condition rather than `sleep`, and prefer semantic locators in e2e tests diff --git a/BSDmakefile b/BSDmakefile new file mode 100644 index 0000000..79696ea --- /dev/null +++ b/BSDmakefile @@ -0,0 +1,58 @@ +# GNU makefile proxy script for BSD make +# +# Written and maintained by Mahmoud Al-Qudsi +# Copyright NeoSmart Technologies 2014-2019 +# Obtain updates from +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +JARG = +GMAKE = "gmake" +# When gmake is called from another make instance, -w is automatically added +# which causes extraneous messages about directory changes to be emitted. +# Running with --no-print-directory silences these messages. +GARGS = "--no-print-directory" + +.if "$(.MAKE.JOBS)" != "" + JARG = -j$(.MAKE.JOBS) +.endif + +# bmake prefers out-of-source builds and tries to cd into ./obj (among others) +# where possible. GNU Make doesn't, so override that value. +.OBJDIR: ./ + +# The GNU convention is to use the lowercased `prefix` variable/macro to +# specify the installation directory. Humor them. +GPREFIX = +.if defined(PREFIX) && ! defined(prefix) + GPREFIX = 'prefix = "$(PREFIX)"' +.endif + +.BEGIN: .SILENT + which $(GMAKE) || (printf "Error: GNU Make is required!\n\n" 1>&2 && false) + +.PHONY: FRC +$(.TARGETS): FRC + $(GMAKE) $(GPREFIX) $(GARGS) $(.TARGETS:S,.DONE,,) $(JARG) + +.DONE .DEFAULT: .SILENT + $(GMAKE) $(GPREFIX) $(GARGS) $(.TARGETS:S,.DONE,,) $(JARG) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6a71263 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,96 @@ +# Gitea Community Code of Conduct + +## About + +Online communities include people from many different backgrounds. The Gitea contributors are committed to providing a friendly, safe and welcoming environment for all, regardless of gender identity and expression, sexual orientation, disabilities, neurodiversity, physical appearance, body size, ethnicity, nationality, race, age, religion, or similar personal characteristics. + +The first goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about Gitea effectively, productively, and respectfully. + +The second goal is to provide a mechanism for resolving conflicts in the community when they arise. + +The third goal of the Code of Conduct is to make our community welcoming to people from different backgrounds. Diversity is critical to the project; for Gitea to be successful, it needs contributors and users from all backgrounds. + +We believe that healthy debate and disagreement are essential to a healthy project and community. However, it is never ok to be disrespectful. We value diverse opinions, but we value respectful behavior more. + +## Community values + +These are the values to which people in the Gitea community should aspire. + +- **Be friendly and welcoming.** +- **Be patient.** + - Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) +- **Be thoughtful.** + - Productive communication requires effort. Think about how your words will be interpreted. + - Remember that sometimes it is best to refrain entirely from commenting. +- **Be respectful.** + - In particular, respect differences of opinion. +- **Be charitable.** + - Interpret the arguments of others in good faith, do not seek to disagree. + - When we do disagree, try to understand why. +- **Be constructive.** + - Avoid derailing: stay on topic; if you want to talk about something else, start a new conversation. + - Avoid unconstructive criticism: don't merely decry the current state of affairs; offer—or at least solicit—suggestions as to how things may be improved. + - Avoid snarking (pithy, unproductive, sniping comments). + - Avoid discussing potentially offensive or sensitive issues; this all too often leads to unnecessary conflict. + - Avoid microaggressions (brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults to a person or group). +- **Be responsible.** + - What you say and do matters. Take responsibility for your words and actions, including their consequences, whether intended or otherwise. + +People are complicated. You should expect to be misunderstood and to misunderstand others; when this inevitably occurs, resist the urge to be defensive or assign blame. Try not to take offense where no offense was intended. Give people the benefit of the doubt. Even if the intent was to provoke, do not rise to it. It is the responsibility of all parties to de-escalate conflict when it arises. + +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject: comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, as well as to ban (temporarily or permanently) any contributor for behaviors that they deem inappropriate, threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project Stewards have a reasonable belief that an individual’s behavior may have a negative impact on the project or its community. + +### Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement often yield positive results. However, it is never okay to be disrespectful or to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, you are encouraged to address the behavior directly with those involved. Many issues can be resolved quickly and easily, and this gives people more control over the outcome of their dispute. If you are unable to resolve the matter for any reason, or if the behavior is threatening or harassing, report it. We are dedicated to providing an environment where participants feel welcome and safe. + +Reports should be directed to the Gitea Project Stewards at conduct@gitea.com. It is the Project Stewards’ duty to receive and address reported violations of the code of conduct. They will then work with a committee consisting of representatives from the technical-oversight-committee. + +We will investigate every complaint, but you may not receive a direct response. We will use our discretion in determining when and how to follow up on reported incidents, which may range from not taking action to permanent expulsion from the project and project-sponsored spaces. Under normal circumstances, we will notify the accused of the report and provide them an opportunity to discuss it before any action is taken. If there is a consensus between maintainers that such an endeavor would be useless (i.e. in case of an obvious spammer), we reserve the right to take action without notifying the accused first. The identity of the reporter will be omitted from the details of the report supplied to the accused. In potentially harmful situations, such as ongoing harassment or threats to anyone’s safety, we may take action without notice. + +### Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +## Summary + +- Treat everyone with respect and kindness. +- Be thoughtful in how you communicate. +- Don’t be destructive or inflammatory. +- If you encounter an issue, please mail conduct@gitea.com. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2f00c0a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,293 @@ +# Contribution Guidelines + +This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find. + +| Topic | Document | +|:-----------------------|:-----------------------------------------------------------------| +| Setup and requirements | [docs/build-setup.md](docs/build-setup.md) | +| Development workflow | [docs/development.md](docs/development.md) | +| Build from source | [docs/build-source.md](docs/build-source.md) | +| Running the tests | [docs/testing.md](docs/testing.md) | +| Frontend guidelines | [docs/guidelines-frontend.md](docs/guidelines-frontend.md) | +| Backend guidelines | [docs/guidelines-backend.md](docs/guidelines-backend.md) | +| Refactoring | [docs/guidelines-refactoring.md](docs/guidelines-refactoring.md) | +| Community Governance | [docs/community-governance.md](docs/community-governance.md) | +| Release management | [docs/release-management.md](docs/release-management.md) | + +
Table of Contents + +- [Contribution Guidelines](#contribution-guidelines) + - [Introduction](#introduction) + - [AI Contribution Policy](#ai-contribution-policy) + - [Issues](#issues) + - [How to report issues](#how-to-report-issues) + - [Types of issues](#types-of-issues) + - [Discuss your design before the implementation](#discuss-your-design-before-the-implementation) + - [Issue locking](#issue-locking) + - [Building Gitea](#building-gitea) + - [Styleguide](#styleguide) + - [Copyright](#copyright) + - [Testing](#testing) + - [Translation](#translation) + - [Code review](#code-review) + - [Pull request format](#pull-request-format) + - [PR title and summary](#pr-title-and-summary) + - [Breaking PRs](#breaking-prs) + - [What is a breaking PR?](#what-is-a-breaking-pr) + - [How to handle breaking PRs?](#how-to-handle-breaking-prs) + - [Maintaining open PRs](#maintaining-open-prs) + - [Reviewing PRs](#reviewing-prs) + - [For PR authors](#for-pr-authors) + - [Documentation](#documentation) + - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) + +
+ +## Introduction + +It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \ +Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io). + +For configuring IDEs for Gitea development, see the [IDE setup notes](docs/development.md#ide-configuration) and the [contributed configurations](contrib/development/). + +## AI Contribution Policy + +Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly and disclose that use clearly. + +1. Review AI-generated code closely before marking a pull request ready for review. +2. Manually test the changes and add appropriate automated tests where feasible. +3. Only use AI to assist in contributions that you understand well enough to explain, defend, and revise yourself during review. +4. Disclose AI-assisted content clearly. +5. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model. +6. AI may be used to help draft issues and pull requests, but contributors remain responsible for the accuracy, completeness, and intent of what they submit. + +Maintainers reserve the right to close pull requests and issues that do not disclose AI assistance, that appear to be low-quality AI-generated content, or where the contributor cannot explain or defend the proposed changes themselves. + +We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process. + +## Issues + +### How to report issues + +Please search the issues on the issue tracker with a variety of related keywords to ensure that your issue has not already been reported. + +If your issue has not been reported yet, [open an issue](https://github.com/go-gitea/gitea/issues/new) +and answer the questions so we can understand and reproduce the problematic behavior. \ +Please write clear and concise instructions so that we can reproduce the behavior — even if it seems obvious. \ +The more detailed and specific you are, the faster we can fix the issue. \ +It is really helpful if you can reproduce your problem on a site running on the latest commits, i.e. , as perhaps your problem has already been fixed on a current version. \ +Please follow the guidelines described in [How to Report Bugs Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) for your report. + +Please be kind—remember that Gitea comes at no cost to you, and you're getting free help. + +### Types of issues + +Typically, issues fall in one of the following categories: + +- `bug`: Something in the frontend or backend behaves unexpectedly +- `security issue`: bug that has serious implications such as leaking another users data. Please do not file such issues on the public tracker and send a mail to security@gitea.io instead +- `feature`: Completely new functionality. You should describe this feature in enough detail that anyone who reads the issue can understand how it is supposed to be implemented +- `enhancement`: An existing feature should get an upgrade +- `refactoring`: Parts of the code base don't conform with other parts and should be changed to improve Gitea's maintainability + +### Discuss your design before the implementation + +We welcome submissions. \ +If you want to change or add something, please let everyone know what you're working on — [file an issue](https://github.com/go-gitea/gitea/issues/new) or comment on an existing one before starting your work! + +Significant changes such as new features must go through the change proposal process before they can be accepted. \ +This is mainly to save yourself the trouble of implementing it, only to find out that your proposed implementation has some potential problems. \ +Furthermore, this process gives everyone a chance to validate the design, helps prevent duplication of effort, and ensures that the idea fits inside +the goals for the project and tools. + +Pull requests should not be the place for architecture discussions. + +### Issue locking + +Commenting on closed or merged issues/PRs is strongly discouraged. +Such comments will likely be overlooked as some maintainers may not view notifications on closed issues, thinking that the item is resolved. +As such, commenting on closed/merged issues/PRs may be disabled prior to the scheduled auto-locking if a discussion starts or if unrelated comments are posted. +If further discussion is needed, we encourage you to open a new issue instead and we recommend linking to the issue/PR in question for context. + +## Building Gitea + +See [docs/build-setup.md](docs/build-setup.md) for prerequisites and [docs/development.md](docs/development.md) +for building Gitea and the development workflow. + +## Styleguide + +You should always run `make fmt` before committing to conform to Gitea's styleguide. + +## Copyright + +New code files that you contribute should use the standard copyright header: + +``` +// Copyright The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +``` + +Afterwards, copyright should only be modified when the copyright author changes. + +## Testing + +Before submitting a pull request, run the linters (`make lint`, or the scoped `make lint-backend` / `make lint-frontend`) and the tests to make sure your changes don't cause a regression elsewhere. See [docs/testing.md](docs/testing.md) for how to run the unit, integration, end-to-end, and migration tests. + +## Translation + +All translation work happens on [Crowdin](https://translate.gitea.com). +The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.json). +It is synced regularly with Crowdin. \ +Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \ +Once a language has reached a **satisfactory percentage** of translated keys (~25%), it will be synced back into this repo and included in the next released version. + +The tool `go run build/backport-locale.go` can be used to backport locales from the main branch to release branches that were missed. + +## Code review + +How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md). + +### Pull request format + +Please try to make your pull request easy to review for us. \ +For that, please read the [*Best Practices for Faster Reviews*](https://github.com/kubernetes/community/blob/261cb0fd089b64002c91e8eddceebf032462ccd6/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) guide. \ +It has lots of useful tips for any project you may want to contribute to. \ +Some of the key points: + +- Make small pull requests. \ + The smaller, the faster to review and the more likely it will be merged soon. +- Don't make changes unrelated to your PR. \ + Maybe there are typos on some comments, maybe refactoring would be welcome on a function... \ + but if that is not related to your PR, please make *another* PR for that. +- Split big pull requests into multiple small ones. \ + An incremental change will be faster to review than a huge PR. +- Allow edits by maintainers. This way, the maintainers will take care of merging the PR later on instead of you. + +### PR title and summary + +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: + +```text +type(scope)!: subject +``` + +The scope in parentheses is optional. A `!` immediately before the colon marks a [breaking change](https://www.conventionalcommits.org/en/v1.0.0/#summary): either `type!:` or `type(scope)!:` (not `type!(scope):`). + +Use one of these types: + +- `build`: Changes affecting the build system, packaging, or external dependencies +- `ci`: Changes to CI/CD configuration files and scripts +- `chore`: Maintenance changes that do not affect production code or should not appear in the changelog +- `docs`: Documentation-only changes +- `feat`: A larger user-facing feature, improvement, or new functionality +- `enhance`: Small or trivial user-facing improvements or UX polish (for example wording changes, color adjustments, spacing or padding tweaks, placeholders, small UI behavior improvements) +- `fix`: A bug fix, UX correction, or security-related dependency update +- `perf`: Performance improvements (speed, memory, scalability) +- `refactor`: A code change that neither fixes a bug nor adds a feature +- `revert`: Reverts a previous change +- `style`: Formatting or style-only changes that do not affect code behavior (for example lint-driven edits) +- `test`: Adding or correcting tests + +Examples: + +```text +fix(web): prevent avatar upload crash on empty file +feat(api): add pagination to repo hooks list +enhance(repo): improve diff toolbar spacing +ci(workflows): lint PR titles in CI +``` + +Keep this summary up-to-date as the PR evolves. \ +If your PR changes the UI, you must add **after** screenshots in the PR summary. \ +If you are not implementing a new feature, you should also post **before** screenshots for comparison. + +If you are implementing a new feature, your PR will only be merged if your screenshots are up to date.\ +Furthermore, feature PRs will only be merged if their summary contains a clear usage description (understandable for users) and testing description (understandable for reviewers). +You should strive to combine both into a single description. + +Another requirement for merging PRs is that the PR is labeled correctly.\ +However, this is not your job as a contributor, but the job of the person merging your PR.\ +If you think that your PR was labeled incorrectly, or notice that it was merged without labels, please let us know. + +For pull requests that use a valid Conventional Commits title, CI automatically applies a matching `type/…` label when the title prefix is `feat`, `enhance`, `fix`, `docs`, or `test` (for example `enhance(web): …` receives `type/enhancement`).\ +That label is kept in sync with the PR title when the title is edited.\ +Other title prefixes do not get an automatic `type/…` label; the merger still assigns the correct labels (including `type/…` when needed) for changelog and backport decisions. + +If your PR closes some issues, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like + +```text +Fixes/Closes/Resolves #. +Fixes/Closes/Resolves #. +``` + +to your summary. \ +Each issue that will be closed must stand on a separate line. + +### Breaking PRs + +#### What is a breaking PR? + +A PR is breaking if it meets one of the following criteria: + +- It changes API output in an incompatible way for existing users +- It removes a setting that an admin could previously set (i.e. via `app.ini`) +- An admin must do something manually to restore the old behavior + +In particular, this means that adding new settings is not breaking.\ +Changing the default value of a setting or replacing the setting with another one is breaking, however. + +#### How to handle breaking PRs? + +If your PR has a breaking change, you must add two things to the summary of your PR: + +1. A reasoning why this breaking change is necessary +2. A `BREAKING` section explaining in simple terms (understandable for a typical user) how this PR affects users and how to mitigate these changes. This section can look for example like + +```md +## :warning: BREAKING :warning: +``` + +Breaking PRs will not be merged as long as not both of these requirements are met. + +### Maintaining open PRs + +Code review starts when you open a non-draft PR or move a draft out of draft state. After that, do not rebase or squash your branch; it makes new changes harder to review. + +Merge the base branch into yours only when you need to, for example because of conflicting changes elsewhere. That limits unnecessary CI runs. + +Every PR is squash-merged, so merge commits on your branch do not matter for final history. The squash produces a single commit; mergers follow the [commit message format](docs/community-governance.md#commit-messages) in the governance guide. + +### Reviewing PRs + +Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest. + +#### For PR authors + +- **Response**: When answering reviewer questions, use real-world cases or examples and avoid speculation. +- **Discussion**: A discussion is always welcome and should be used to clarify the changes and the intent of the PR. +- **Help**: If you need help with the PR or comments are unclear, ask for clarification. + +Guidance for reviewers, the merge queue, and the squash commit message format is in [docs/community-governance.md](docs/community-governance.md). + +## Documentation + +If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs). +**The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.** + +## Developer Certificate of Origin (DCO) + +We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \ +No further action is required. \ +You can also decide to sign off your commits by adding the following line at the end of your commit messages: + +``` +Signed-off-by: Joe Smith +``` + +If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`. + +We assume in good faith that the information you provide is legally binding. diff --git a/DCO b/DCO new file mode 100644 index 0000000..49b8cb0 --- /dev/null +++ b/DCO @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6210aef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 +# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.27-alpine3.24 AS frontend-build +RUN apk --no-cache add build-base git nodejs pnpm +WORKDIR /src +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +COPY --exclude=.git/ . . +RUN make frontend + +# Build backend for each target platform +FROM docker.io/library/golang:1.27-alpine3.24 AS build-env + +ARG GITEA_VERSION +ARG TAGS="" +ENV TAGS="bindata timetzdata $TAGS" +ARG CGO_EXTRA_CFLAGS + +# Build deps +RUN apk --no-cache add \ + build-base \ + git + +WORKDIR ${GOPATH}/src/gitea.dev +COPY go.mod go.sum ./ +RUN go mod download +# Use COPY instead of bind mount as read-only one breaks makefile state tracking and read-write one needs binary to be moved as it's discarded. +# ".git" directory is mounted separately later only for version data extraction. +COPY --exclude=.git/ . . +COPY --from=frontend-build /src/public/assets public/assets + +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=bind,source=".git/",target=".git/" \ + make backend + +COPY docker/root /tmp/local + +# Set permissions for builds that made under windows which strips the executable bit from file +RUN chmod 755 /tmp/local/usr/bin/entrypoint \ + /tmp/local/usr/local/bin/* \ + /tmp/local/etc/s6/gitea/* \ + /tmp/local/etc/s6/openssh/* \ + /tmp/local/etc/s6/.s6-svscan/* \ + /go/src/gitea.dev/gitea + +FROM docker.io/library/alpine:3.24 AS gitea + +EXPOSE 22 3000 + +RUN apk --no-cache add \ + bash \ + ca-certificates \ + curl \ + gettext \ + git \ + linux-pam \ + openssh \ + s6 \ + sqlite \ + su-exec \ + gnupg + +RUN addgroup \ + -S -g 1000 \ + git && \ + adduser \ + -S -H -D \ + -h /data/git \ + -s /bin/bash \ + -u 1000 \ + -G git \ + git && \ + echo "git:*" | chpasswd -e + +COPY --from=build-env /tmp/local / +COPY --from=build-env /go/src/gitea.dev/gitea /app/gitea/gitea + +ENV USER=git +ENV GITEA_CUSTOM=/data/gitea + +VOLUME ["/data"] + +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information +ENTRYPOINT ["/usr/bin/entrypoint"] +CMD ["/usr/bin/s6-svscan", "/etc/s6"] diff --git a/Dockerfile.rootless b/Dockerfile.rootless new file mode 100644 index 0000000..d7ce351 --- /dev/null +++ b/Dockerfile.rootless @@ -0,0 +1,88 @@ +# syntax=docker/dockerfile:1 +# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.27-alpine3.24 AS frontend-build +RUN apk --no-cache add build-base git nodejs pnpm +WORKDIR /src +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +COPY --exclude=.git/ . . +RUN make frontend + +# Build backend for each target platform +FROM docker.io/library/golang:1.27-alpine3.24 AS build-env + +ARG GITEA_VERSION +ARG TAGS="" +ENV TAGS="bindata timetzdata $TAGS" +ARG CGO_EXTRA_CFLAGS + +# Build deps +RUN apk --no-cache add \ + build-base \ + git + +WORKDIR ${GOPATH}/src/gitea.dev +COPY go.mod go.sum ./ +RUN go mod download +# See the comments in Dockerfile +COPY --exclude=.git/ . . +COPY --from=frontend-build /src/public/assets public/assets + +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=bind,source=".git/",target=".git/" \ + make backend + +COPY docker/rootless /tmp/local + +# Set permissions for builds that made under windows which strips the executable bit from file +RUN chmod 755 /tmp/local/usr/local/bin/* \ + /go/src/gitea.dev/gitea + +FROM docker.io/library/alpine:3.24 AS gitea-rootless + +EXPOSE 2222 3000 + +RUN apk --no-cache add \ + bash \ + ca-certificates \ + dumb-init \ + gettext \ + git \ + curl \ + gnupg \ + openssh-keygen + +RUN addgroup \ + -S -g 1000 \ + git && \ + adduser \ + -S -H -D \ + -h /var/lib/gitea/git \ + -s /bin/bash \ + -u 1000 \ + -G git \ + git + +RUN mkdir -p /var/lib/gitea /etc/gitea +RUN chown git:git /var/lib/gitea /etc/gitea + +COPY --from=build-env /tmp/local / +COPY --from=build-env --chown=root:root /go/src/gitea.dev/gitea /app/gitea/gitea + +# git:git +USER 1000:1000 +ENV GITEA_WORK_DIR=/var/lib/gitea +ENV GITEA_CUSTOM=/var/lib/gitea/custom +ENV GITEA_TEMP=/tmp/gitea +ENV TMPDIR=/tmp/gitea + +# TODO add to docs the ability to define the ini to load (useful to test and revert a config) +ENV GITEA_APP_INI=/etc/gitea/app.ini +ENV HOME="/var/lib/gitea/git" +VOLUME ["/var/lib/gitea", "/etc/gitea"] +WORKDIR /var/lib/gitea + +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information +ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8d4b49 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016 The Gitea Authors +Copyright (c) 2015 The Gogs Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..87463a3 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1,67 @@ +Alexey Makhov (@makhov) +Bo-Yi Wu (@appleboy) +Ethan Koenig (@ethantkoenig) +Kees de Vries (@Bwko) +Kim Carlbäcker (@bkcsoft) +LefsFlare (@LefsFlarey) +Lunny Xiao (@lunny) +Rachid Zarouali (@xinity) +Rémy Boulanouar (@DblK) +Sandro Santilli (@strk) +Thibault Meyer (@0xbaadf00d) +Thomas Boerger (@tboerger) +Patrick G (@geek1011) +Antoine Girard (@sapk) +Lauris Bukšis-Haberkorns (@lafriks) +Jonas Östanbäck (@cez81) +David Schneiderbauer (@daviian) +Peter Žeby (@morlinest) +Matti Ranta (@techknowlogick) +Jonas Franz (@jonasfranz) +Alexey Terentyev (@axifive) +Lanre Adelowo (@adelowo) +Konrad Langenberg (@kolaente) +He-Long Zhang (@BetaCat0) +Andrew Thornton (@zeripath) +John Olheiser (@jolheiser) +Richard Mahn (@richmahn) +Mrsdizzie (@mrsdizzie) +silverwind (@silverwind) +Gary Kim (@gary-kim) +Guillermo Prandi (@guillep2k) +Mura Li (@typeless) +6543 <6543@obermui.de> (@6543) +David Svantesson (@davidsvantesson) +a1012112796 <1012112796@qq.com> (@a1012112796) +Karl Heinz Marbaise (@khmarbaise) +Norwin Roosen (@noerw) +Kyle Dumont (@kdumontnu) +Janis Estelmann (@KN4CK3R) +Jimmy Praet (@jpraet) +Leon Hofmeister (@delvh) +Wim (@42wim) +Jason Song (@wolfogre) +Yu Tian (@Zettat123) +Dong Ge (@sillyguodong) +Xinyi Gong (@HesterG) +wxiaoguang (@wxiaoguang) +Gary Moon (@garymoon) +Philip Peterson (@philip-peterson) +Denys Konovalov (@denyskon) +Punit Inani (@puni9869) +CaiCandong <1290147055@qq.com> (@caicandong) +Rui Chen (@chenrui333) +Nanguan Lin (@lng2020) +kerwin612 (@kerwin612) +Gary Wang (@BLumia) +Tim-Niclas Oelschläger (@zokkis) +Yu Liu <1240335630@qq.com> (@HEREYUA) +Kemal Zebari (@kemzeb) +Rowan Bohde (@bohde) +hiifong (@hiifong) +metiftikci (@metiftikci) +Christopher Homberger (@ChristopherHX) +Tobias Balle-Petersen (@tobiasbp) +TheFox (@TheFox0x7) +Nicolas (@bircni) +Minjie Fang (@wingsallen) \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..363a8be --- /dev/null +++ b/Makefile @@ -0,0 +1,659 @@ +DIST := dist +DIST_DIRS := $(DIST)/binaries $(DIST)/release + +GO ?= go +SHASUM ?= shasum -a 256 + +AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go +EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.1 # renovate: datasource=go +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go +GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.16 # renovate: datasource=go +MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go +SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.5 # renovate: datasource=go +GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go +ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go +SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d # renovate: datasource=docker + +CONTAINER_RUNTIME ?= $(shell hash docker >/dev/null 2>&1 && echo docker || echo podman) + +PLAYWRIGHT_BROWSERS ?= chromium firefox +PLAYWRIGHT_FLAGS ?= + +HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes) +ifeq ($(HAS_GO), yes) + CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766 + CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS) +endif + +MAKE_EVIDENCE_DIR := .make_evidence + +# Use sqlite as default database if running tests, only do so for local tests, not in CI. +# CI should explicitly set the database to avoid unexpected results. +ifneq ($(findstring test-,$(MAKECMDGOALS)),) + ifeq ($(CI),) + GITEA_TEST_DATABASE ?= sqlite + endif +endif + +TAGS ?= +TAGS_EVIDENCE := $(MAKE_EVIDENCE_DIR)/tags +CGO_TAGS := sqlite_mattn pam + +CGO_ENABLED ?= 0 +ifneq ($(strip $(filter $(CGO_TAGS),$(TAGS))),) + CGO_ENABLED = 1 +endif + +ifeq ($(GOOS),windows) + IS_WINDOWS := yes +else ifeq ($(patsubst Windows%,Windows,$(OS)),Windows) + ifeq ($(GOOS),) + IS_WINDOWS := yes + endif +endif + +ifeq ($(IS_WINDOWS),yes) + EXECUTABLE ?= gitea.exe +else + EXECUTABLE ?= gitea +endif + +# EXTRA_GOFLAGS is for the 'go build' command only +EXTRA_GOFLAGS ?= + +ifeq ($(shell sed --version 2>/dev/null | grep -q GNU && echo gnu),gnu) + SED_INPLACE := sed -i +else + SED_INPLACE := sed -i '' +endif + +# GOTEST_FLAGS is for unit test and integration test +GOTEST_FLAGS ?= -timeout 40m + +STORED_VERSION_FILE := VERSION + +GITHUB_REF_TYPE ?= branch +GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) + +# VERSION: the branch name for the build and filenames, e.g.: "feature/foo-bar", "main" +# branch name "release/v1.27.2" is stripped to "1.27.2". +# GITEA_VERSION: the Gitea's internal version for display, e.g. "1.28.0+dev-356-ge47d0b66ea" +ifeq ($(GITHUB_REF_TYPE),tag) + # convert tag "v1.2.3" to "1.2.3" + VERSION ?= $(subst v,,$(GITHUB_REF_NAME)) + GITEA_VERSION ?= $(VERSION) +else ifeq ($(GITHUB_REF_TYPE),branch) + ifneq ($(GITHUB_REF_NAME),) + # convert branch "release/v1.2" to "1.2-nightly" + VERSION ?= $(subst release/v,,$(GITHUB_REF_NAME))-nightly + else + # no branch name info, use git ref name "HEAD" instead + VERSION ?= HEAD + endif + + STORED_VERSION=$(shell cat $(STORED_VERSION_FILE) 2>/dev/null) + ifneq ($(STORED_VERSION),) + GITEA_VERSION ?= $(STORED_VERSION) + else + GITEA_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') + endif +else + $(error unsupported ref type $(GITHUB_REF_TYPE)) +endif + +# if version == "main" then add "-nightly" to the version for nightly builds: "main-nightly" +ifeq ($(VERSION),main) + VERSION := main-nightly +endif + +LDFLAGS := $(LDFLAGS) -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)" +RELEASE_ENV = GO="$(GO)" TAGS="$(TAGS)" LDFLAGS="$(LDFLAGS)" DIST="$(DIST)" VERSION="$(VERSION)" + +GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list gitea.dev/modelmigration/...) gitea.dev/tests/integration/migration-test gitea.dev/tests gitea.dev/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/)) +MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list gitea.dev/modelmigration/...) + +FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f) +FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts +FRONTEND_DEST := public/assets/.vite/manifest.json +FRONTEND_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/.vite +FRONTEND_DEV_LOG_LEVEL ?= warn + +BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.* + +GENERATED_GO_DEST := modules/charset/invisible_gen.go modules/charset/ambiguous_gen.go + +SVG_DEST_DIR := public/assets/img/svg +SVG_DEST_DIRS := $(SVG_DEST_DIR) options/fileicon + +AIR_TMP_DIR := .air + +GO_LICENSE_FILE := assets/go-licenses.json + +TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR) + +GO_DIRS := build cmd modelmigration models modules routers services tests tools +WEB_DIRS := web_src/js web_src/css + +ESLINT_FILES := web_src/js tools *.ts tests/e2e +STYLELINT_FILES := web_src/css web_src/js/components/*.vue +SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) +EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json + +GO_SOURCES := $(wildcard *.go) +GO_SOURCES += $(shell find $(GO_DIRS) -type f -name "*.go") +GO_SOURCES += $(GENERATED_GO_DEST) + +ESLINT_CONCURRENCY ?= 2 +ESLINT_ARGS := --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY) + +SWAGGER_EXCLUDE := gitea.dev/sdk +SWAGGER_SPEC_INPUT := templates/swagger/v1-input.json +SWAGGER_SPEC := templates/swagger/v1-swagger.generated.json +OPENAPI3_SPEC := templates/swagger/v1-openapi3.generated.json + +TEST_MYSQL_HOST ?= mysql:3306 +TEST_MYSQL_DBNAME ?= testgitea +TEST_MYSQL_USERNAME ?= root +TEST_MYSQL_PASSWORD ?= +TEST_PGSQL_HOST ?= pgsql:5432 +TEST_PGSQL_DBNAME ?= testgitea +TEST_PGSQL_USERNAME ?= postgres +TEST_PGSQL_PASSWORD ?= postgres +TEST_PGSQL_SCHEMA ?= gtestschema +TEST_MINIO_ENDPOINT ?= minio:9000 +TEST_MSSQL_HOST ?= mssql:1433 +TEST_MSSQL_DBNAME ?= testgitea +TEST_MSSQL_USERNAME ?= sa +TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1 + +# Include local Makefile +# Makefile.local is listed in .gitignore +ifneq ("$(wildcard Makefile.local)","") + include Makefile.local +endif + +$(foreach v, $(filter TEST_%, $(.VARIABLES)), $(eval MAKEFILE_VARS+=$v=$($v))) +$(foreach v, $(filter GITEA_TEST_%, $(.VARIABLES)), $(eval MAKEFILE_VARS+=$v=$($v))) +export MAKEFILE_VARS + +.PHONY: all +all: build + +.PHONY: help +help: Makefile ## print Makefile help information. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST) + @printf " \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright" + @printf " \033[36m%-46s\033[0m %s\n" "test-backend[#TestSpecificName]" "run unit test (sqlite only)" + @printf " \033[36m%-46s\033[0m %s\n" "test-integration[#TestSpecificName]" "run integration test for GITEA_TEST_DATABASE (sqlite, mysql, pgsql, mssql)" + +.PHONY: clean-all +clean-all: clean ## delete backend, frontend and integration files + rm -rf $(FRONTEND_DEST_ENTRIES) node_modules + +.PHONY: clean +clean: ## delete backend and integration files + rm -f $(EXECUTABLE) test-*.test tests/*.ini + rm -rf $(DIST) $(BINDATA_DEST_WILDCARD) man tests/integration/gitea-integration-* + +.PHONY: fmt +fmt: ## format the Go and template code + $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt + $(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl')) + @# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only + @# whitespace before it + @$(SED_INPLACE) \ + -e 's/{{[ ]\{1,\}/{{/g' -e '/^[ ]\{1,\}}}/! s/[ ]\{1,\}}}/}}/g' \ + -e 's/([ ]\{1,\}/(/g' -e '/^[ ]\{1,\})/! s/[ ]\{1,\})/)/g' \ + $(TEMPLATES) + +.PHONY: fmt-check +fmt-check: fmt + @diff=$$(git diff --color=always $(GO_SOURCES) templates); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make fmt' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: $(TAGS_EVIDENCE) +$(TAGS_EVIDENCE): + @mkdir -p $(MAKE_EVIDENCE_DIR) + @echo "$(TAGS)" > $(TAGS_EVIDENCE) + +ifneq "$(TAGS)" "$(shell cat $(TAGS_EVIDENCE) 2>/dev/null)" +TAGS_PREREQ := $(TAGS_EVIDENCE) +endif + +.PHONY: generate-swagger +generate-swagger: $(SWAGGER_SPEC) $(OPENAPI3_SPEC) ## generate the swagger spec from code comments + +$(SWAGGER_SPEC): $(GO_SOURCES) $(SWAGGER_SPEC_INPUT) + @output="$$($(GO) run $(SWAGGER_PACKAGE) generate spec --enable-allof-compounding --skip-enum-desc --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)' 2>&1)" || { printf '%s\n' "$$output" >&2; exit 1; }; \ + warnings="$$(printf '%s\n' "$$output" | grep -v '^go: ')"; \ + if [ -n "$$warnings" ]; then printf '%s\n' "$$warnings" >&2; exit 1; fi + +.PHONY: swagger-check +swagger-check: generate-swagger + @diff=$$(git diff --color=always '$(SWAGGER_SPEC)'); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make generate-swagger' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: swagger-validate +swagger-validate: ## check if the swagger spec is valid + @# ensure no warnings + @output="$$($(GO) run $(SWAGGER_PACKAGE) validate './$(SWAGGER_SPEC)' 2>&1)"; status=$$?; \ + printf '%s\n' "$$output" | grep -v '^go: '; \ + case "$$output" in *WARNING:*) exit 1;; esac; exit $$status + +.PHONY: generate-openapi3 +generate-openapi3: $(OPENAPI3_SPEC) ## generate the OpenAPI 3.0 spec from the Swagger 2.0 spec + +$(OPENAPI3_SPEC): $(SWAGGER_SPEC) build/generate-openapi.go $(wildcard build/openapi3gen/*.go) + $(GO) run build/generate-openapi.go + +.PHONY: openapi3-check +openapi3-check: generate-openapi3 + @diff=$$(git diff --color=always '$(OPENAPI3_SPEC)'); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make generate-openapi3' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: checks +checks: checks-frontend checks-backend ## run various consistency checks + +.PHONY: checks-frontend +checks-frontend: lockfile-check svg-check ## check frontend files + +.PHONY: checks-backend +checks-backend: tidy-check swagger-check openapi3-check fmt-check swagger-validate security-check ## check backend files + +.PHONY: lint +lint: lint-frontend lint-backend lint-templates lint-swagger lint-spell lint-md lint-actions lint-json lint-yaml lint-shell ## lint everything + +.PHONY: lint-fix +lint-fix: lint-frontend-fix lint-backend-fix lint-spell-fix ## lint everything and fix issues + +.PHONY: lint-frontend +lint-frontend: lint-js lint-css ## lint frontend files + +.PHONY: lint-frontend-fix +lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues + +.PHONY: lint-backend +lint-backend: lint-go lint-editorconfig ## lint backend files + +.PHONY: lint-backend-fix +lint-backend-fix: lint-go-fix lint-editorconfig ## lint backend files and fix issues + +.PHONY: lint-js +lint-js: node_modules ## lint js and ts files + pnpm exec eslint $(ESLINT_ARGS) $(ESLINT_FILES) + pnpm exec vue-tsc + +.PHONY: lint-js-fix +lint-js-fix: node_modules ## lint js and ts files and fix issues + pnpm exec eslint $(ESLINT_ARGS) $(ESLINT_FILES) --fix + pnpm exec vue-tsc + +.PHONY: lint-css +lint-css: node_modules ## lint css files + pnpm exec stylelint --color --max-warnings=0 $(STYLELINT_FILES) + +.PHONY: lint-css-fix +lint-css-fix: node_modules ## lint css files and fix issues + pnpm exec stylelint --color --max-warnings=0 $(STYLELINT_FILES) --fix + +.PHONY: lint-swagger +lint-swagger: node_modules ## lint swagger files + pnpm exec spectral lint -q -F hint $(SWAGGER_SPEC) $(OPENAPI3_SPEC) + +.PHONY: lint-md +lint-md: node_modules ## lint markdown files + pnpm exec markdownlint *.md + +.PHONY: lint-md-fix +lint-md-fix: node_modules ## lint markdown files and fix issues + pnpm exec markdownlint --fix *.md + +.PHONY: lint-spell +lint-spell: ## lint spelling + @git ls-files $(SPELLCHECK_FILES) | xargs go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -error + +.PHONY: lint-spell-fix +lint-spell-fix: ## lint spelling and fix issues + @git ls-files $(SPELLCHECK_FILES) | xargs go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -w + +.PHONY: lint-go +lint-go: ## lint go files + GO=$(GO) GOLANGCI_LINT_PACKAGE=$(GOLANGCI_LINT_PACKAGE) $(GO) run ./tools/lint-go-all.go + +.PHONY: lint-go-fix +lint-go-fix: ## lint go files and fix issues + GO=$(GO) GOLANGCI_LINT_PACKAGE=$(GOLANGCI_LINT_PACKAGE) $(GO) run ./tools/lint-go-all.go --fix + +.PHONY: lint-editorconfig +lint-editorconfig: + @echo "Running editorconfig check..." + @$(GO) run $(EDITORCONFIG_CHECKER_PACKAGE) $(EDITORCONFIG_FILES) + +.PHONY: lint-actions +lint-actions: .venv ## lint action workflow files + @$(GO) run $(ACTIONLINT_PACKAGE) + @uv run --frozen zizmor --quiet --persona=pedantic --min-confidence=medium .github + +.PHONY: lint-shell +lint-shell: ## lint shell scripts + @SHELLCHECK_IMAGE=$(SHELLCHECK_IMAGE) CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/lint-shell.sh $$(git ls-files '*.sh') + +.PHONY: lint-templates +lint-templates: .venv node_modules ## lint template files + @node tools/lint-templates-svg.ts + @uv run --frozen djlint $(shell find templates -type f -iname '*.tmpl') + +.PHONY: lint-yaml +lint-yaml: .venv ## lint yaml files + @uv run --frozen yamllint -s . + +.PHONY: lint-json +lint-json: node_modules ## lint json files + pnpm exec eslint -c eslint.json.config.ts $(ESLINT_ARGS) + +.PHONY: lint-json-fix +lint-json-fix: node_modules ## lint and fix json files + pnpm exec eslint -c eslint.json.config.ts $(ESLINT_ARGS) --fix + +.PHONY: watch +watch: ## watch everything and continuously rebuild + @bash tools/watch.sh + +.PHONY: watch-frontend +watch-frontend: node_modules ## start vite dev server for frontend + NODE_ENV=development pnpm exec vite --logLevel $(FRONTEND_DEV_LOG_LEVEL) + +.PHONY: watch-backend +watch-backend: ## watch backend files and continuously rebuild + GITEA_RUN_MODE=dev $(GO) run $(AIR_PACKAGE) -c .air.toml + +.PHONY: test-backend +test-backend: ## test backend files + @echo "Running go test with $(GOTEST_FLAGS) -tags '$(TAGS)'..." + @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' $(GO_TEST_PACKAGES) + +.PHONY: test-frontend +test-frontend: playwright ## test frontend files + pnpm exec vitest + +.PHONY: test-check +test-check: + @echo "Running test-check..."; + @diff=$$(git status -s); \ + if [ -n "$$diff" ]; then \ + echo "make test-backend has changed files in the source tree:"; \ + printf "%s" "$${diff}"; \ + echo "You should change the tests to create these files in a temporary directory."; \ + echo "Do not simply add these files to .gitignore"; \ + exit 1; \ + fi + +.PHONY: test-backend\#% +test-backend\#%: + @echo "Running go test with -tags '$(TAGS)'..." + @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' -run $(subst .,/,$*) $(GO_TEST_PACKAGES) + +.PHONY: coverage +coverage: + grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' coverage.out > coverage-bodged.out + grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' integration.coverage.out > integration.coverage-bodged.out + $(GO) run tools/gocovmerge.go integration.coverage-bodged.out coverage-bodged.out > coverage.all + +.PHONY: unit-test-coverage +unit-test-coverage: + @echo "Running unit-test-coverage $(GOTEST_FLAGS) -tags '$(TAGS)'..." + @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' -cover -coverprofile coverage.out $(GO_TEST_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 + +.PHONY: tidy +tidy: ## run go mod tidy + $(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2)) + $(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2)) + $(GO) mod tidy -compat=$(MIN_GO_VERSION) + @# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it + @if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \ + $(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \ + fi + @$(MAKE) --no-print-directory $(GO_LICENSE_FILE) + +vendor: go.mod go.sum + $(GO) mod vendor + @touch vendor + +.PHONY: tidy-check +tidy-check: tidy + @diff=$$(git diff --color=always go.mod go.sum $(GO_LICENSE_FILE)); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make tidy' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: go-licenses +go-licenses: $(GO_LICENSE_FILE) ## regenerate go licenses + +$(GO_LICENSE_FILE): go.mod go.sum + GO=$(GO) $(GO) run build/generate-go-licenses.go $(GO_LICENSE_FILE) + +.PHONY: test-integration +test-integration: $(EXECUTABLE) + @# Use a compiled binary: testlogger forwards gitea logs to t.Log, so `go test -v` + @# would flood output per passing test. testcache can't help these tests anyway — + @# they mutate the work directory, so cache inputs change between runs. + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -c gitea.dev/tests/integration -o ./test-integration-$(GITEA_TEST_DATABASE).test + ./tools/test-integration.sh ./test-integration-$(GITEA_TEST_DATABASE).test + +.PHONY: test-integration-compile +test-integration-compile: + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -c -o /dev/null gitea.dev/tests/integration + +.PHONY: test-integration\#% +test-integration\#%: $(EXECUTABLE) + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -run $(subst .,/,$*) gitea.dev/tests/integration + +.PHONY: test-migration +test-migration: migrations.integration.test migrations.individual.test + +.PHONY: migrations.integration.test +migrations.integration.test: + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' gitea.dev/tests/integration/migration-test + +.PHONY: migrations.individual.test +migrations.individual.test: + @# tests of multiple packages use the same database, don't run in parallel + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + +.PHONY: migrations.individual.test\#% +migrations.individual.test\#%: + $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' gitea.dev/modelmigration/$* + +.PHONY: playwright +playwright: deps-frontend + @./tools/playwright.sh $(PLAYWRIGHT_FLAGS) $(PLAYWRIGHT_BROWSERS) + +.PHONY: test-e2e +test-e2e: playwright frontend backend + @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) + +.PHONY: build +build: frontend backend ## build everything + +.PHONY: frontend +frontend: $(FRONTEND_DEST) ## build frontend files + +.PHONY: backend +backend: generate-backend $(EXECUTABLE) ## build backend files + +# We generate the backend before the frontend in case we in future we want to generate things in the frontend from generated files in backend +.PHONY: generate +generate: generate-backend ## run "go generate" + +.PHONY: generate-backend +generate-backend: $(TAGS_PREREQ) generate-go + +.PHONY: generate-go +generate-go: $(TAGS_PREREQ) + @echo "Running go generate..." + @CC= GOOS= GOARCH= CGO_ENABLED=0 $(GO) generate -tags '$(TAGS)' ./... + +.PHONY: security-check +security-check: + go run $(GOVULNCHECK_PACKAGE) -show color ./... || true + +$(EXECUTABLE): $(GO_SOURCES) $(TAGS_PREREQ) + CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build -v $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)' -o $@ + +$(DIST_DIRS): + mkdir -p $(DIST_DIRS) + +# Release builds always use Go's native cross compilation. To cross-compile with CGO, +# use "build" target with proper TAGS/LDFLAGS/CGO_CFLAGS to make "$(EXECUTABLE)" target run the "go build" command. +.PHONY: release +release: frontend release-binaries release-copy release-compress vendor release-sources release-check + +.PHONY: release-binaries +release-binaries: | $(DIST_DIRS) + @$(RELEASE_ENV) ./tools/build-release.sh + +.PHONY: release-windows +release-windows: | $(DIST_DIRS) + @$(RELEASE_ENV) ./tools/build-release.sh windows + +.PHONY: release-linux +release-linux: | $(DIST_DIRS) + @$(RELEASE_ENV) ./tools/build-release.sh linux + +.PHONY: release-darwin +release-darwin: | $(DIST_DIRS) + @$(RELEASE_ENV) ./tools/build-release.sh darwin + +.PHONY: release-freebsd +release-freebsd: | $(DIST_DIRS) + @$(RELEASE_ENV) ./tools/build-release.sh freebsd + +.PHONY: release-copy +release-copy: | $(DIST_DIRS) + cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done; + +.PHONY: release-check +release-check: | $(DIST_DIRS) + cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done; + +.PHONY: release-compress +release-compress: | $(DIST_DIRS) + cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done; + +.PHONY: release-sources +release-sources: | $(DIST_DIRS) + echo $(VERSION) > $(STORED_VERSION_FILE) +# bsdtar needs a ^ to prevent matching subdirectories + $(eval EXCL := --exclude=$(shell tar --help | grep -q bsdtar && echo "^")./) +# use transform to a add a release-folder prefix; in bsdtar the transform parameter equivalent is -s + $(eval TRANSFORM := $(shell tar --help | grep -q bsdtar && echo "-s '|^./|gitea-src-$(VERSION)/|'" || echo "--transform 's|^./|gitea-src-$(VERSION)/|'")) + tar $(addprefix $(EXCL),$(TAR_EXCLUDES)) $(TRANSFORM) -czf $(DIST)/release/gitea-src-$(VERSION).tar.gz . + rm -f $(STORED_VERSION_FILE) + +.PHONY: deps +deps: deps-frontend deps-backend deps-tools deps-py ## install dependencies + +.PHONY: deps-py +deps-py: .venv ## install python dependencies + +.PHONY: deps-frontend +deps-frontend: node_modules ## install frontend dependencies + +.PHONY: deps-backend +deps-backend: ## install backend dependencies + $(GO) mod download + +.PHONY: deps-tools +deps-tools: ## install tool dependencies + $(GO) install $(AIR_PACKAGE) & \ + $(GO) install $(EDITORCONFIG_CHECKER_PACKAGE) & \ + $(GO) install $(GOLANGCI_LINT_PACKAGE) & \ + $(GO) install $(GXZ_PACKAGE) & \ + $(GO) install $(MISSPELL_PACKAGE) & \ + $(GO) install $(SWAGGER_PACKAGE) & \ + $(GO) install $(GOVULNCHECK_PACKAGE) & \ + $(GO) install $(ACTIONLINT_PACKAGE) & \ + wait + +node_modules: pnpm-lock.yaml + pnpm install --frozen-lockfile + @touch node_modules + +.venv: uv.lock + uv sync + @touch .venv + +.PHONY: vite +vite: $(FRONTEND_DEST) ## build vite files + +$(FRONTEND_DEST): $(FRONTEND_SOURCES) $(FRONTEND_CONFIGS) pnpm-lock.yaml + @$(MAKE) -s node_modules + @rm -rf $(FRONTEND_DEST_ENTRIES) + @echo "Running vite build..." + @pnpm exec vite build + @touch $(FRONTEND_DEST) + +.PHONY: svg +svg: node_modules ## build svg files + rm -rf $(SVG_DEST_DIR) + node tools/generate-svg.ts + +.PHONY: svg-check +svg-check: svg + @git add $(SVG_DEST_DIRS) + @diff=$$(git diff --color=always --cached $(SVG_DEST_DIRS)); \ + if [ -n "$$diff" ]; then \ + echo "Please run 'make svg' and 'git add $(SVG_DEST_DIRS)' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: lockfile-check +lockfile-check: + pnpm install --frozen-lockfile + @diff=$$(git diff --color=always pnpm-lock.yaml); \ + if [ -n "$$diff" ]; then \ + echo "pnpm-lock.yaml is inconsistent with package.json"; \ + echo "Please run 'pnpm install --frozen-lockfile' and commit the result:"; \ + printf "%s" "$${diff}"; \ + exit 1; \ + fi + +.PHONY: generate-gitignore +generate-gitignore: ## update gitignore files + $(GO) run build/generate-gitignores.go + +.PHONY: generate-images +generate-images: | node_modules ## generate images + cd tools && node generate-images.ts $(TAGS) + +.PHONY: generate-codemirror-languages +generate-codemirror-languages: | node_modules ## generate codemirror languages + node tools/generate-codemirror-languages.ts + +.PHONY: generate-manpage +generate-manpage: ## generate manpage + @[ -f gitea ] || make backend + @mkdir -p man/man1/ man/man5 + @./gitea docs --man > man/man1/gitea.1 + @gzip -9 man/man1/gitea.1 && echo man/man1/gitea.1.gz created + @#TODO A small script that formats config-cheat-sheet.en-us.md nicely for use as a config man page + +# Disable parallel execution because it would break some targets that don't +# specify exact dependencies like 'backend' which does currently not depend +# on 'frontend' to enable Node.js-less builds from source tarballs. +.NOTPARALLEL: diff --git a/README.md b/README.md new file mode 100644 index 0000000..e581ea6 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# Gitea + +[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") +[![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc") +[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") +[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") +[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") +[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") +[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") + +[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md) + +## Purpose + +The goal of Gitea is to make the easiest, fastest, and most painless way of +setting up a self-hosted all-in-one software development service, +including Git hosting, code management, code review, issue tracking, project kanban, wiki, +team collaboration, package registry and CI/CD which can reuse GitHub Actions. + +As Gitea is written in Go, it works across **all** the platforms and +architectures that are supported by Go, including Linux, macOS, FreeBSD/OpenBSD and Windows +on x86, amd64, ARM, RISC-V 64 and PowerPC architectures. + +For online demonstrations, you can visit [demo.gitea.com](https://demo.gitea.com). + +For accessing free Gitea service (with a limited number of repositories), you can visit [gitea.com](https://gitea.com/user/login). + +To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com), +or use container (docker/podman/etc) to deploy on your own server with the [official image](https://hub.docker.com/r/gitea/gitea). + +## Documentation + +You can find comprehensive documentation on our official [documentation website](https://docs.gitea.com/). + +It includes installation, administration, usage, development, contributing guides, and more to help you get started and explore all features effectively. + +If you have any suggestions or would like to contribute to it, you can visit the [documentation repository](https://gitea.com/gitea/docs) + +## Building + +See [docs/build-setup.md](docs/build-setup.md) for prerequisites +and [docs/development.md](docs/development.md) for setting up a local development environment, linting, and testing. + +If you'd like to build from source or make a distribution package, see [docs/build-source.md](docs/build-source.md) for more information. + +After building, you can run `./gitea web` to start the server, or `./gitea help` to see all available commands. + +## Contributing + +Expected workflow is: Fork -> Patch -> Push -> Pull Request + +> [!NOTE] +> +> 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.** +> 2. New to the codebase? The [development guide](docs/development.md) walks through setting up a local environment and building from source. +> 3. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks! + +## Translating + +[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com) + +Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language, ask one of the managers in the Crowdin project to add a new language there. + +You can also just create an issue for adding a language or ask on Discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. + +Get more information from [the translation section of our contributing guide](CONTRIBUTING.md#translation). + +## Official and Third-Party Projects + +We provide an official [go-sdk](https://gitea.com/gitea/go-sdk), a CLI tool called [tea](https://gitea.com/gitea/tea) and an [action runner](https://gitea.com/gitea/runner) for Gitea Action. + +We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea), where you can discover more third-party projects, including SDKs, plugins, themes, and more. + +## Communication + +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") + +If you have questions that are not covered by the [documentation](https://docs.gitea.com/), you can get in contact with us on our [Discord server](https://discord.gg/Gitea) or create a post in the [discourse forum](https://forum.gitea.com/). + +## Authors + +- [Maintainers](https://github.com/orgs/go-gitea/people) +- [Contributors](https://github.com/go-gitea/gitea/graphs/contributors) +- [Translators](options/locale/TRANSLATORS) + +## Backers + +Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/gitea#backer)] + + + +## Sponsors + +Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/gitea#sponsor)] + + + + + + + + + + + + +## FAQ + +**How do you pronounce Gitea?** + +Gitea is pronounced [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea" with a hard g. + +**How do I configure Gitea?** + +For dynamic config options, you can change it on your admin panel's configuration section. + +For static config options, you can edit your `app.ini` file and restart the instance. +See [app.example.ini](https://github.com/go-gitea/gitea/blob/main/custom/conf/app.example.ini) or [configuration documentation](https://docs.gitea.com/administration/config-cheat-sheet) for more details. + +**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. + +(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq)) + +## License + +This project is licensed under the MIT License. +See the [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) file +for the full license text. + +## Further information + +
+Looking for an overview of the interface? Check it out the screenshots! + +### Login/Register Page + +![Login](https://dl.gitea.com/screenshots/login.png) +![Register](https://dl.gitea.com/screenshots/register.png) + +### User Dashboard + +![Home](https://dl.gitea.com/screenshots/home.png) +![Issues](https://dl.gitea.com/screenshots/issues.png) +![Pull Requests](https://dl.gitea.com/screenshots/pull_requests.png) +![Milestones](https://dl.gitea.com/screenshots/milestones.png) + +### User Profile + +![Profile](https://dl.gitea.com/screenshots/user_profile.png) + +### Explore + +![Repos](https://dl.gitea.com/screenshots/explore_repos.png) +![Users](https://dl.gitea.com/screenshots/explore_users.png) +![Orgs](https://dl.gitea.com/screenshots/explore_orgs.png) + +### Repository + +![Home](https://dl.gitea.com/screenshots/repo_home.png) +![Commits](https://dl.gitea.com/screenshots/repo_commits.png) +![Branches](https://dl.gitea.com/screenshots/repo_branches.png) +![Labels](https://dl.gitea.com/screenshots/repo_labels.png) +![Milestones](https://dl.gitea.com/screenshots/repo_milestones.png) +![Releases](https://dl.gitea.com/screenshots/repo_releases.png) +![Tags](https://dl.gitea.com/screenshots/repo_tags.png) + +#### Repository Issue + +![List](https://dl.gitea.com/screenshots/repo_issues.png) +![Issue](https://dl.gitea.com/screenshots/repo_issue.png) + +#### Repository Pull Requests + +![List](https://dl.gitea.com/screenshots/repo_pull_requests.png) +![Pull Request](https://dl.gitea.com/screenshots/repo_pull_request.png) +![File](https://dl.gitea.com/screenshots/repo_pull_request_file.png) +![Commits](https://dl.gitea.com/screenshots/repo_pull_request_commits.png) + +#### Repository Actions + +![List](https://dl.gitea.com/screenshots/repo_actions.png) +![Details](https://dl.gitea.com/screenshots/repo_actions_run.png) + +#### Repository Activity + +![Activity](https://dl.gitea.com/screenshots/repo_activity.png) +![Contributors](https://dl.gitea.com/screenshots/repo_contributors.png) +![Code Frequency](https://dl.gitea.com/screenshots/repo_code_frequency.png) +![Recent Commits](https://dl.gitea.com/screenshots/repo_recent_commits.png) + +### Organization + +![Home](https://dl.gitea.com/screenshots/org_home.png) + +
diff --git a/README.zh-cn.md b/README.zh-cn.md new file mode 100644 index 0000000..46c4db3 --- /dev/null +++ b/README.zh-cn.md @@ -0,0 +1,200 @@ +# Gitea + +[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") +[![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc") +[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") +[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") +[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") +[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") +[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") + +[English](./README.md) | [繁體中文](./README.zh-tw.md) + +## 目的 + +这个项目的目标是提供最简单、最快速、最无痛的方式来设置自托管的 Git 服务。 + +由于 Gitea 是用 Go 语言编写的,它可以在 Go 支持的所有平台和架构上运行,包括 Linux、macOS 和 Windows 的 x86、amd64、ARM 和 PowerPC 架构。这个项目自 2016 年 11 月从 [Gogs](https://gogs.io) [分叉](https://blog.gitea.com/welcome-to-gitea/) 而来,但已经有了很多变化。 + +在线演示可以访问 [demo.gitea.com](https://demo.gitea.com)。 + +要访问免费的 Gitea 服务(有一定数量的仓库限制),可以访问 [gitea.com](https://gitea.com/user/login)。 + +要快速部署您自己的专用 Gitea 实例,可以在 [cloud.gitea.com](https://cloud.gitea.com) 开始免费试用。 + +## 文件 + +您可以在我们的官方 [文件网站](https://docs.gitea.com/) 上找到全面的文件。 + +它包括安装、管理、使用、开发、贡献指南等,帮助您快速入门并有效地探索所有功能。 + +如果您有任何建议或想要贡献,可以访问 [文件仓库](https://gitea.com/gitea/docs) + +## 构建 + +从源代码树的根目录运行: + + TAGS="bindata" make build + +`build` 目标分为两个子目标: + +- `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定义。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 + +需要互联网连接来下载 go 和 npm 模块。从包含预构建前端文件的官方源代码压缩包构建时,不会触发 `frontend` 目标,因此可以在没有 Node.js 的情况下构建。 + +更多信息:https://docs.gitea.com/installation/install-from-source + +## 使用 + +构建后,默认情况下会在源代码树的根目录生成一个名为 `gitea` 的二进制文件。要运行它,请使用: + + ./gitea web + +> [!注意] +> 如果您对使用我们的 API 感兴趣,我们提供了实验性支持,并附有 [文件](https://docs.gitea.com/api)。 + +## 贡献 + +预期的工作流程是:Fork -> Patch -> Push -> Pull Request + +> [!注意] +> +> 1. **在开始进行 Pull Request 之前,您必须阅读 [贡献者指南](CONTRIBUTING.md)。** +> 2. 如果您在项目中发现了漏洞,请私下写信给 **security@gitea.io**。谢谢! + +## 翻译 + +[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com) + +翻译通过 [Crowdin](https://translate.gitea.com) 进行。如果您想翻译成新的语言,请在 Crowdin 项目中请求管理员添加新语言。 + +您也可以创建一个 issue 来添加语言,或者在 discord 的 #translation 频道上询问。如果您需要上下文或发现一些翻译问题,可以在字符串上留言或在 Discord 上询问。对于一般的翻译问题,文档中有一个部分。目前有点空,但我们希望随着问题的出现而填充它。 + +更多信息请参阅 [文件](https://docs.gitea.com/zh-cn/contributing/localization)。 + +## 官方和第三方项目 + +我们提供了一个官方的 [go-sdk](https://gitea.com/gitea/go-sdk),一个名为 [tea](https://gitea.com/gitea/tea) 的 CLI 工具和一个 Gitea Action 的 [action runner](https://gitea.com/gitea/runner)。 + +我们在 [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea) 维护了一个 Gitea 相关项目的列表,您可以在那里发现更多的第三方项目,包括 SDK、插件、主题等。 + +## 通讯 + +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") + +如果您有任何文件未涵盖的问题,可以在我们的 [Discord 服务器](https://discord.gg/Gitea) 上与我们联系,或者在 [discourse 论坛](https://forum.gitea.com/) 上创建帖子。 + +## 作者 + +- [维护者](https://github.com/orgs/go-gitea/people) +- [贡献者](https://github.com/go-gitea/gitea/graphs/contributors) +- [翻译者](options/locale/TRANSLATORS) + +## 支持者 + +感谢所有支持者! 🙏 [[成为支持者](https://opencollective.com/gitea#backer)] + + + +## 赞助商 + +通过成为赞助商来支持这个项目。您的标志将显示在这里,并带有链接到您的网站。 [[成为赞助商](https://opencollective.com/gitea#sponsor)] + + + + + + + + + + + + +## 常见问题 + +**Gitea 怎么发音?** + +Gitea 的发音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea" 一样,g 是硬音。 + +**为什么这个项目没有托管在 Gitea 实例上?** + +我们正在 [努力](https://github.com/go-gitea/gitea/issues/1029)。 + +**在哪里可以找到安全补丁?** + +在 [发布日志](https://github.com/go-gitea/gitea/releases) 或 [变更日志](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索关键词 `SECURITY` 以找到安全补丁。 + +## 许可证 + +这个项目是根据 MIT 许可证授权的。 +请参阅 [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) 文件以获取完整的许可证文本。 + +## 进一步信息 + +
+寻找界面概述?查看这里! + +### 登录/注册页面 + +![Login](https://dl.gitea.com/screenshots/login.png) +![Register](https://dl.gitea.com/screenshots/register.png) + +### 用户仪表板 + +![Home](https://dl.gitea.com/screenshots/home.png) +![Issues](https://dl.gitea.com/screenshots/issues.png) +![Pull Requests](https://dl.gitea.com/screenshots/pull_requests.png) +![Milestones](https://dl.gitea.com/screenshots/milestones.png) + +### 用户资料 + +![Profile](https://dl.gitea.com/screenshots/user_profile.png) + +### 探索 + +![Repos](https://dl.gitea.com/screenshots/explore_repos.png) +![Users](https://dl.gitea.com/screenshots/explore_users.png) +![Orgs](https://dl.gitea.com/screenshots/explore_orgs.png) + +### 仓库 + +![Home](https://dl.gitea.com/screenshots/repo_home.png) +![Commits](https://dl.gitea.com/screenshots/repo_commits.png) +![Branches](https://dl.gitea.com/screenshots/repo_branches.png) +![Labels](https://dl.gitea.com/screenshots/repo_labels.png) +![Milestones](https://dl.gitea.com/screenshots/repo_milestones.png) +![Releases](https://dl.gitea.com/screenshots/repo_releases.png) +![Tags](https://dl.gitea.com/screenshots/repo_tags.png) + +#### 仓库问题 + +![List](https://dl.gitea.com/screenshots/repo_issues.png) +![Issue](https://dl.gitea.com/screenshots/repo_issue.png) + +#### 仓库拉取请求 + +![List](https://dl.gitea.com/screenshots/repo_pull_requests.png) +![Pull Request](https://dl.gitea.com/screenshots/repo_pull_request.png) +![File](https://dl.gitea.com/screenshots/repo_pull_request_file.png) +![Commits](https://dl.gitea.com/screenshots/repo_pull_request_commits.png) + +#### 仓库操作 + +![List](https://dl.gitea.com/screenshots/repo_actions.png) +![Details](https://dl.gitea.com/screenshots/repo_actions_run.png) + +#### 仓库活动 + +![Activity](https://dl.gitea.com/screenshots/repo_activity.png) +![Contributors](https://dl.gitea.com/screenshots/repo_contributors.png) +![Code Frequency](https://dl.gitea.com/screenshots/repo_code_frequency.png) +![Recent Commits](https://dl.gitea.com/screenshots/repo_recent_commits.png) + +### 组织 + +![Home](https://dl.gitea.com/screenshots/org_home.png) + +
diff --git a/README.zh-tw.md b/README.zh-tw.md new file mode 100644 index 0000000..2257ca1 --- /dev/null +++ b/README.zh-tw.md @@ -0,0 +1,200 @@ +# Gitea + +[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") +[![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc") +[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") +[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") +[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") +[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") +[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") + +[English](./README.md) | [简体中文](./README.zh-cn.md) + +## 目的 + +這個項目的目標是提供最簡單、最快速、最無痛的方式來設置自託管的 Git 服務。 + +由於 Gitea 是用 Go 語言編寫的,它可以在 Go 支援的所有平台和架構上運行,包括 Linux、macOS 和 Windows 的 x86、amd64、ARM 和 PowerPC 架構。這個項目自 2016 年 11 月從 [Gogs](https://gogs.io) [分叉](https://blog.gitea.com/welcome-to-gitea/) 而來,但已經有了很多變化。 + +在線演示可以訪問 [demo.gitea.com](https://demo.gitea.com)。 + +要訪問免費的 Gitea 服務(有一定數量的倉庫限制),可以訪問 [gitea.com](https://gitea.com/user/login)。 + +要快速部署您自己的專用 Gitea 實例,可以在 [cloud.gitea.com](https://cloud.gitea.com) 開始免費試用。 + +## 文件 + +您可以在我們的官方 [文件網站](https://docs.gitea.com/) 上找到全面的文件。 + +它包括安裝、管理、使用、開發、貢獻指南等,幫助您快速入門並有效地探索所有功能。 + +如果您有任何建議或想要貢獻,可以訪問 [文件倉庫](https://gitea.com/gitea/docs) + +## 構建 + +從源代碼樹的根目錄運行: + + TAGS="bindata" make build + +`build` 目標分為兩個子目標: + +- `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定義。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 + +需要互聯網連接來下載 go 和 npm 模塊。從包含預構建前端文件的官方源代碼壓縮包構建時,不會觸發 `frontend` 目標,因此可以在沒有 Node.js 的情況下構建。 + +更多信息:https://docs.gitea.com/installation/install-from-source + +## 使用 + +構建後,默認情況下會在源代碼樹的根目錄生成一個名為 `gitea` 的二進制文件。要運行它,請使用: + + ./gitea web + +> [!注意] +> 如果您對使用我們的 API 感興趣,我們提供了實驗性支援,並附有 [文件](https://docs.gitea.com/api)。 + +## 貢獻 + +預期的工作流程是:Fork -> Patch -> Push -> Pull Request + +> [!注意] +> +> 1. **在開始進行 Pull Request 之前,您必須閱讀 [貢獻者指南](CONTRIBUTING.md)。** +> 2. 如果您在項目中發現了漏洞,請私下寫信給 **security@gitea.io**。謝謝! + +## 翻譯 + +[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com) + +翻譯通過 [Crowdin](https://translate.gitea.com) 進行。如果您想翻譯成新的語言,請在 Crowdin 項目中請求管理員添加新語言。 + +您也可以創建一個 issue 來添加語言,或者在 discord 的 #translation 頻道上詢問。如果您需要上下文或發現一些翻譯問題,可以在字符串上留言或在 Discord 上詢問。對於一般的翻譯問題,文檔中有一個部分。目前有點空,但我們希望隨著問題的出現而填充它。 + +更多信息請參閱 [文件](https://docs.gitea.com/zh-tw/contributing/localization)。 + +## 官方和第三方項目 + +我們提供了一個官方的 [go-sdk](https://gitea.com/gitea/go-sdk),一個名為 [tea](https://gitea.com/gitea/tea) 的 CLI 工具和一個 Gitea Action 的 [action runner](https://gitea.com/gitea/runner)。 + +我們在 [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea) 維護了一個 Gitea 相關項目的列表,您可以在那裡發現更多的第三方項目,包括 SDK、插件、主題等。 + +## 通訊 + +[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") + +如果您有任何文件未涵蓋的問題,可以在我們的 [Discord 服務器](https://discord.gg/Gitea) 上與我們聯繫,或者在 [discourse 論壇](https://forum.gitea.com/) 上創建帖子。 + +## 作者 + +- [維護者](https://github.com/orgs/go-gitea/people) +- [貢獻者](https://github.com/go-gitea/gitea/graphs/contributors) +- [翻譯者](options/locale/TRANSLATORS) + +## 支持者 + +感謝所有支持者! 🙏 [[成為支持者](https://opencollective.com/gitea#backer)] + + + +## 贊助商 + +通過成為贊助商來支持這個項目。您的標誌將顯示在這裡,並帶有鏈接到您的網站。 [[成為贊助商](https://opencollective.com/gitea#sponsor)] + + + + + + + + + + + + +## 常見問題 + +**Gitea 怎麼發音?** + +Gitea 的發音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea" 一樣,g 是硬音。 + +**為什麼這個項目沒有託管在 Gitea 實例上?** + +我們正在 [努力](https://github.com/go-gitea/gitea/issues/1029)。 + +**在哪裡可以找到安全補丁?** + +在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 或 [變更日誌](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。 + +## 許可證 + +這個項目是根據 MIT 許可證授權的。 +請參閱 [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) 文件以獲取完整的許可證文本。 + +## 進一步信息 + +
+尋找界面概述?查看這裡! + +### 登錄/註冊頁面 + +![Login](https://dl.gitea.com/screenshots/login.png) +![Register](https://dl.gitea.com/screenshots/register.png) + +### 用戶儀表板 + +![Home](https://dl.gitea.com/screenshots/home.png) +![Issues](https://dl.gitea.com/screenshots/issues.png) +![Pull Requests](https://dl.gitea.com/screenshots/pull_requests.png) +![Milestones](https://dl.gitea.com/screenshots/milestones.png) + +### 用戶資料 + +![Profile](https://dl.gitea.com/screenshots/user_profile.png) + +### 探索 + +![Repos](https://dl.gitea.com/screenshots/explore_repos.png) +![Users](https://dl.gitea.com/screenshots/explore_users.png) +![Orgs](https://dl.gitea.com/screenshots/explore_orgs.png) + +### 倉庫 + +![Home](https://dl.gitea.com/screenshots/repo_home.png) +![Commits](https://dl.gitea.com/screenshots/repo_commits.png) +![Branches](https://dl.gitea.com/screenshots/repo_branches.png) +![Labels](https://dl.gitea.com/screenshots/repo_labels.png) +![Milestones](https://dl.gitea.com/screenshots/repo_milestones.png) +![Releases](https://dl.gitea.com/screenshots/repo_releases.png) +![Tags](https://dl.gitea.com/screenshots/repo_tags.png) + +#### 倉庫問題 + +![List](https://dl.gitea.com/screenshots/repo_issues.png) +![Issue](https://dl.gitea.com/screenshots/repo_issue.png) + +#### 倉庫拉取請求 + +![List](https://dl.gitea.com/screenshots/repo_pull_requests.png) +![Pull Request](https://dl.gitea.com/screenshots/repo_pull_request.png) +![File](https://dl.gitea.com/screenshots/repo_pull_request_file.png) +![Commits](https://dl.gitea.com/screenshots/repo_pull_request_commits.png) + +#### 倉庫操作 + +![List](https://dl.gitea.com/screenshots/repo_actions.png) +![Details](https://dl.gitea.com/screenshots/repo_actions_run.png) + +#### 倉庫活動 + +![Activity](https://dl.gitea.com/screenshots/repo_activity.png) +![Contributors](https://dl.gitea.com/screenshots/repo_contributors.png) +![Code Frequency](https://dl.gitea.com/screenshots/repo_code_frequency.png) +![Recent Commits](https://dl.gitea.com/screenshots/repo_recent_commits.png) + +### 組織 + +![Home](https://dl.gitea.com/screenshots/org_home.png) + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5195370 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,94 @@ +# Reporting security issues + +The Gitea maintainers take security seriously. + +If you discover a security issue, please bring it to their attention right away! + +Previous vulnerabilities are listed at https://about.gitea.com/security. + +## Supported Versions + +Security fixes follow our [support policy](docs/release-management.md#end-of-life-eol): only the most recent major release and the `main` branch receive them. + +Reports must be filed against a supported version. Before reporting, verify that the issue reproduces on a supported version; any report that only affects an unsupported version will be closed as out of scope. + +## Reporting a Vulnerability + +Please **DO NOT** file a public issue. Instead, report the vulnerability privately by opening a [GitHub security advisory](https://github.com/go-gitea/gitea/security/advisories/new), or optionally by sending your report to `security@gitea.io`. +If reporting via GitHub, open an advisory per found issue. You can group the found issues if they relate to a single area (same feature, same fault present in multiple routes) but do not group unrelated reports into one as it makes it much harder to assess and track for duplicates. +Do not use LLM to write the reports. It's meant for people to read so take your time and use your own words to write it. If you have used LLM to find the bug, be transparent about it and verify that it is not hallucination before submitting the report. + +If your report turns out to be a duplicate, you will be credited in the original report. + +## Protecting Security Information + +Due to the sensitive nature of security information, you can use the below GPG public key to encrypt your mail body. + +The PGP key is valid until July 23, 2027. + +``` +Key ID: 6FCD2D5B +Key Type: RSA +Expires: 7/23/2027 +Key Size: 4096/4096 +Fingerprint: 3DE0 3D1E 144A 7F06 9359 99DC AAFD 2381 6FCD 2D5B +``` + +UserID: Gitea Security + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGK1Z/4BEADFMqXA9DeeChmSxUjF0Be5sq99ZUhgrZjcN/wOzz0wuCJZC0l8 +4uC+d6mfv7JpJYlzYzOK97/x5UguKHkYNZ6mm1G9KHaXmoIBDLKDzfPdJopVNv2r +OajijaE0uMCnMjadlg5pbhMLRQG8a9J32yyaz7ZEAw72Ab31fvvcA53NkuqO4j2w +k7dtFQzhbNOYV0VffQT90WDZdalYHB1JHyEQ+70U9OjVD5ggNYSzX98Eu3Hjn7V7 +kqFrcAxr5TE1elf0IXJcuBJtFzQSTUGlQldKOHtGTGgGjj9r/FFAE5ioBgVD05bV +rEEgIMM/GqYaG/nbNpWE6P3mEc2Mnn3pZaRJL0LuF26TLjnqEcMMDp5iIhLdFzXR +3tMdtKgQFu+Mtzs3ipwWARYgHyU09RJsI2HeBx7RmZO/Xqrec763Z7zdJ7SpCn0Z +q+pHZl24JYR0Kf3T/ZiOC0cGd2QJqpJtg5J6S/OqfX9NH6MsCczO8pUC1N/aHH2X +CTme2nF56izORqDWKoiICteL3GpYsCV9nyCidcCmoQsS+DKvE86YhIhVIVWGRY2F +lzpAjnN9/KLtQroutrm+Ft0mdjDiJUeFVl1cOHDhoyfCsQh62HumoyZoZvqzQd6e +AbN11nq6aViMe2Q3je1AbiBnRnQSHxt1Tc8X4IshO3MQK1Sk7oPI6LA5oQARAQAB +tCJHaXRlYSBTZWN1cml0eSA8c2VjdXJpdHlAZ2l0ZWEuaW8+iQJXBBMBCABBAhsD +BQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAFiEEPeA9HhRKfwaTWZncqv0jgW/N +LVsFAmpi3IkFCQmOqAsACgkQqv0jgW/NLVsgyxAAuTyj4+PImOFr7ZkEJcseWUy+ +gWQrJDagj+Ks2SKGneVroJ63q5Vngx44b7JNabauKel4IJJCScLdwA7k0DKFHPOM +rUS6aavW5+961CMQxJ13vWz7qxLxhI4FXle255xFSXgOo9T1W7+wtm/wtze8+sws +8XKAVBZGuVI1/KzVpE6bWxpIIYK79DC07NWGDAZzMbuO92TFHBRblXsicPWExBjC +/oFmT70YMcA0wOwU/YKYMg2eK2XYUr5+uYX6FnP7iUhDj1vZmwkF33Cj9v9D3Hv9 +NzAquj97uLht4wL2sqsIgZd4Vc37uipPDFaddzTb/lon7lPbJPMLObcd4HjSTkIo +nEMM0wdOQZU7mIIgRM9o3mr/gyVigl3uARmUKaP9PhBaOGTjkni0nrv90hSHBHeu +ayM76vl2tzAB2KpDxHIHbI2SEdytV28l/rxWrsbk2kG7s7QB6VKiThTatYevh1kg +6Ujgl2xNSntG6t6PWjQ65OMSq5Iiwe7RfOZkNmPRGP/rbzMbgWXB3iCR2gVQvsxA +ctYdDZdUYAeQ5g6xSKJBloygDQdLzk9m0hqNUyl6gxhRNRw69hqp9E4mxFF3L1Gi +Z2LspLX1ZZWMh0bp0AniWzZuzLuNI0KYhlok6fnshjidJ1qYM/lbz5eJTYWxoJZW +rczn8gB3+MZbULc/iiO5Ag0EYrVn/gEQALrFLQjCR3GjuHSindz0rd3Fnx/t7Sen +T+p07yCSSoSlmnJHCQmwh4vfg1blyz0zZ4vkIhtpHsEgc+ZAG+WQXSsJ2iRz+eSN +GwoOQl4XC3n+QWkc1ws+btr48+6UqXIQU+F8TPQyx/PIgi2nZXJB7f5+mjCqsk46 +XvH4nTr4kJjuqMSR/++wvre2qNQRa/q/dTsK0OaN/mJsdX6Oi+aGNaQJUhIG7F+E +ZDMkn/O6xnwWNzy/+bpg43qH/Gk0eakOmz5NmQLRkV58SZLiJvuCUtkttf6CyhnX +03OcWaajv5W8qA39dBYQgDrrPbBWUnwfO3yMveqhwV4JjDoe8sPAyn1NwzakNYqP +RzsWyLrLS7R7J9s3FkZXhQw/QQcsaSMcGNQO047dm1P83N8JY5aEpiRo9zSWjoiw +qoExANj5lUTZPe8M50lI182FrcjAN7dClO3QI6pg7wy0erMxfFly3j8UQ91ysS9T +s+GsP9I3cmWWQcKYxWHtE8xTXnNCVPFZQj2nwhJzae8ypfOtulBRA3dUKWGKuDH/ +axFENhUsT397aOU3qkP/od4a64JyNIEo4CTTSPVeWd7njsGqli2U3A4xL2CcyYvt +D/MWcMBGEoLSNTswwKdom4FaJpn5KThnK/T0bQcmJblJhoCtppXisbexZnCpuS0x +Zdlm2T14KJ3LABEBAAGJAjwEGAEIACYCGwwWIQQ94D0eFEp/BpNZmdyq/SOBb80t +WwUCamLcngUJCY6oIAAKCRCq/SOBb80tWxilD/41xF1pwD2lGqjWLmlXcd0Ok5xm +67TSGiKiFZUgI3ftkRVlqmeJqjVmRjpc2dLfCuVBzlaP0Oyv5Jn+2CPqgmWIk5tw +pqlxqmsXiIZ9Uv2wWItxrXIwQ6DHc1qcIOu7feTnt1HVhxp2GqlXl3tA/+MTTDmn +4rqrHffI7ps9eCbK/SfSuoQk3+x/x4GPqWDbsNw1E9dDjPPhsKfdwzZ6dFR2Yhzu +2U0GDc2aeBA0Gjy6VFBj1ksmGxQCL3TqtGEHOonoPpTIjmDd35Xq6BSu119onOQS +THZ7+3JQRpizBVrIklKf33UOikZy9WHuffFddP46m8qC2Alh1aB7xcD8K3P9gzd5 +OJO9Zr8JR+rvi8eOsCHvJqGhe0JMMf9bgXKjQ1tMTDTe6r0Jnw7HbkDghjpj9xG7 +GYmDB0CL4kP5nAZ1OPfIasIu3Llhcy6mS52ECw0X6PNwUINwBn7F6EKAiciw8/OS +CXoGJDI5alwNhh+3DL4oBH1aq1lnmxmhFNpwpRDvtbRGmZeeASTeC4LMAtqP85Er +8W9ulIgQ4Ub8QuoxvE5hzWEB1QUkJgozI8vO4DoGlrsz7P8cPzrOT7WqUfUPTYE6 +oZEfEYDSj1IZHnPYPS+yLBJMJ/Lc1h8OCetmbJZx4QWaX3Fgar7Zks8FfJQOTN6i +HnOlrsaMDYNyO76uBQ== +=4kVJ +-----END PGP PUBLIC KEY BLOCK----- +``` + +Security reports are greatly appreciated and we will publicly thank you for it, although we keep your name confidential if you request it. diff --git a/assets/codemirror-languages.json b/assets/codemirror-languages.json new file mode 100644 index 0000000..359c0ac --- /dev/null +++ b/assets/codemirror-languages.json @@ -0,0 +1,1296 @@ +[ + { + "name": "APL", + "extensions": [ + "apl", + "dyalog" + ], + "filenames": [] + }, + { + "name": "ASN.1", + "extensions": [ + "asn", + "asn1" + ], + "filenames": [] + }, + { + "name": "Brainfuck", + "extensions": [ + "b", + "bf" + ], + "filenames": [] + }, + { + "name": "C", + "extensions": [ + "c", + "cats", + "h", + "idc" + ], + "filenames": [] + }, + { + "name": "C#", + "extensions": [ + "cs", + "cake", + "csx", + "linq" + ], + "filenames": [] + }, + { + "name": "C++", + "extensions": [ + "cpp", + "c++", + "cc", + "cp", + "cppm", + "cxx", + "h++", + "hh", + "hpp", + "hxx", + "inl", + "ipp", + "ixx", + "re", + "tcc", + "tpp", + "txx" + ], + "filenames": [] + }, + { + "name": "Clojure", + "extensions": [ + "clj", + "bb", + "boot", + "cl2", + "cljc", + "cljscm", + "cljx", + "hic" + ], + "filenames": [ + "riemann.config" + ] + }, + { + "name": "CMake", + "extensions": [ + "cmake" + ], + "filenames": [ + "CMakeLists.txt" + ] + }, + { + "name": "Cobol", + "extensions": [ + "cob", + "cbl", + "ccp", + "cobol", + "cpy" + ], + "filenames": [] + }, + { + "name": "CoffeeScript", + "extensions": [ + "coffee", + "_coffee", + "cake", + "cjsx", + "iced" + ], + "filenames": [ + "Cakefile" + ] + }, + { + "name": "Common Lisp", + "extensions": [ + "lisp", + "asd", + "cl", + "l", + "lsp", + "ny", + "podsl", + "sexp" + ], + "filenames": [] + }, + { + "name": "CQL", + "extensions": [ + "cql" + ], + "filenames": [] + }, + { + "name": "Crystal", + "extensions": [ + "cr" + ], + "filenames": [] + }, + { + "name": "CSS", + "extensions": [ + "css" + ], + "filenames": [] + }, + { + "name": "Cypher", + "extensions": [ + "cyp", + "cypher" + ], + "filenames": [] + }, + { + "name": "Cython", + "extensions": [ + "pyx", + "pxd", + "pxi" + ], + "filenames": [] + }, + { + "name": "D", + "extensions": [ + "d", + "di" + ], + "filenames": [] + }, + { + "name": "Dart", + "extensions": [ + "dart" + ], + "filenames": [] + }, + { + "name": "diff", + "extensions": [ + "diff", + "patch" + ], + "filenames": [] + }, + { + "name": "Dylan", + "extensions": [ + "dylan", + "dyl", + "intr", + "lid" + ], + "filenames": [] + }, + { + "name": "EBNF", + "extensions": [ + "ebnf" + ], + "filenames": [] + }, + { + "name": "ECL", + "extensions": [ + "ecl", + "eclxml" + ], + "filenames": [] + }, + { + "name": "edn", + "extensions": [ + "edn" + ], + "filenames": [] + }, + { + "name": "Eiffel", + "extensions": [ + "e" + ], + "filenames": [] + }, + { + "name": "Elm", + "extensions": [ + "elm" + ], + "filenames": [] + }, + { + "name": "Erlang", + "extensions": [ + "erl", + "app", + "es", + "escript", + "hrl", + "xrl", + "yrl" + ], + "filenames": [ + "Emakefile", + "rebar.config", + "rebar.config.lock", + "rebar.lock" + ] + }, + { + "name": "F#", + "extensions": [ + "fs", + "fsi", + "fsx" + ], + "filenames": [] + }, + { + "name": "Factor", + "extensions": [ + "factor" + ], + "filenames": [ + ".factor-boot-rc", + ".factor-rc" + ] + }, + { + "name": "Forth", + "extensions": [ + "fth", + "4th", + "forth", + "fr", + "frt" + ], + "filenames": [] + }, + { + "name": "Fortran", + "extensions": [ + "f", + "f77", + "for", + "fpp" + ], + "filenames": [] + }, + { + "name": "Gherkin", + "extensions": [ + "feature", + "story" + ], + "filenames": [] + }, + { + "name": "Go", + "extensions": [ + "go" + ], + "filenames": [] + }, + { + "name": "Groovy", + "extensions": [ + "groovy", + "grt", + "gtpl", + "gvy" + ], + "filenames": [ + "Jenkinsfile" + ] + }, + { + "name": "Haskell", + "extensions": [ + "hs", + "hs-boot", + "hsc" + ], + "filenames": [] + }, + { + "name": "Haxe", + "extensions": [ + "hx", + "hxsl" + ], + "filenames": [] + }, + { + "name": "HTML", + "extensions": [ + "html", + "hta", + "htm", + "xht", + "xhtml" + ], + "filenames": [] + }, + { + "name": "HTTP", + "extensions": [ + "http" + ], + "filenames": [] + }, + { + "name": "HXML", + "extensions": [ + "hxml" + ], + "filenames": [] + }, + { + "name": "IDL", + "extensions": [ + "pro", + "dlm" + ], + "filenames": [] + }, + { + "name": "Java", + "extensions": [ + "java", + "jav", + "jsh" + ], + "filenames": [] + }, + { + "name": "JavaScript", + "extensions": [ + "js", + "_js", + "bones", + "cjs", + "es", + "es6", + "frag", + "gs", + "jake", + "javascript", + "jsb", + "jscad", + "jsfl", + "jslib", + "jsm", + "jspre", + "jss", + "mjs", + "njs", + "pac", + "sjs", + "ssjs", + "xsjs", + "xsjslib" + ], + "filenames": [ + "Jakefile" + ] + }, + { + "name": "Jinja", + "extensions": [ + "jinja", + "j2", + "jinja2" + ], + "filenames": [] + }, + { + "name": "JSON", + "extensions": [ + "json", + "4DForm", + "4DProject", + "avsc", + "geojson", + "gltf", + "har", + "ice", + "JSON-tmLanguage", + "jsonl", + "mcmeta", + "sarif", + "slnlaunch", + "tact", + "tfstate", + "topojson", + "webapp", + "webmanifest", + "yy", + "yyp" + ], + "filenames": [ + ".all-contributorsrc", + ".arcconfig", + ".auto-changelog", + ".c8rc", + ".htmlhintrc", + ".imgbotconfig", + ".nycrc", + ".releaserc", + ".secrets.baseline", + ".tern-config", + ".tern-project", + ".watchmanconfig", + "MODULE.bazel.lock", + "Package.resolved", + "Pipfile.lock", + "bun.lock", + "composer.lock", + "deno.lock", + "flake.lock", + "mcmod.info" + ] + }, + { + "name": "JSON-LD", + "extensions": [ + "jsonld" + ], + "filenames": [] + }, + { + "name": "Julia", + "extensions": [ + "jl" + ], + "filenames": [] + }, + { + "name": "Kotlin", + "extensions": [ + "kt", + "ktm", + "kts" + ], + "filenames": [] + }, + { + "name": "LaTeX", + "extensions": [ + "tex", + "aux", + "bbx", + "cbx", + "cls", + "dtx", + "ins", + "lbx", + "ltx", + "mkii", + "mkiv", + "mkvi", + "sty", + "toc" + ], + "filenames": [] + }, + { + "name": "LESS", + "extensions": [ + "less" + ], + "filenames": [] + }, + { + "name": "Liquid", + "extensions": [ + "liquid" + ], + "filenames": [] + }, + { + "name": "LiveScript", + "extensions": [ + "ls", + "_ls" + ], + "filenames": [ + "Slakefile" + ] + }, + { + "name": "Lua", + "extensions": [ + "lua", + "nse", + "p8", + "pd_lua", + "rbxs", + "rockspec", + "wlua" + ], + "filenames": [ + ".luacheckrc" + ] + }, + { + "name": "Modelica", + "extensions": [ + "mo" + ], + "filenames": [] + }, + { + "name": "Nginx", + "extensions": [ + "nginx", + "nginxconf", + "vhost" + ], + "filenames": [ + "nginx.conf" + ] + }, + { + "name": "NSIS", + "extensions": [ + "nsi", + "nsh" + ], + "filenames": [] + }, + { + "name": "Objective-C", + "extensions": [], + "filenames": [] + }, + { + "name": "Objective-C++", + "extensions": [ + "mm" + ], + "filenames": [] + }, + { + "name": "OCaml", + "extensions": [ + "ml", + "eliom", + "eliomi", + "ml4", + "mli", + "mll", + "mly" + ], + "filenames": [] + }, + { + "name": "Oz", + "extensions": [ + "oz" + ], + "filenames": [] + }, + { + "name": "Pascal", + "extensions": [ + "pas", + "dfm", + "dpr", + "lpr", + "pascal" + ], + "filenames": [] + }, + { + "name": "Perl", + "extensions": [ + "pl", + "al", + "perl", + "ph", + "plx", + "pm", + "psgi", + "t" + ], + "filenames": [ + ".latexmkrc", + "Makefile.PL", + "Rexfile", + "ack", + "cpanfile", + "latexmkrc" + ] + }, + { + "name": "PHP", + "extensions": [ + "php", + "aw", + "ctp", + "php3", + "php4", + "php5", + "phps", + "phpt" + ], + "filenames": [ + ".php", + ".php_cs", + ".php_cs.dist", + "Phakefile" + ] + }, + { + "name": "PLSQL", + "extensions": [ + "pls", + "bdy", + "ddl", + "fnc", + "pck", + "pkb", + "pks", + "plb", + "plsql", + "prc", + "spc", + "tpb", + "tps", + "trg", + "vw" + ], + "filenames": [] + }, + { + "name": "PowerShell", + "extensions": [ + "ps1", + "psd1", + "psm1" + ], + "filenames": [] + }, + { + "name": "Properties files", + "extensions": [ + "ini", + "cnf", + "container", + "dof", + "lektorproject", + "mount", + "network", + "prefs", + "properties", + "service", + "socket", + "target", + "timer", + "url", + "conf" + ], + "filenames": [ + ".buckconfig", + ".coveragerc", + ".flake8", + ".gitreview", + ".pylintrc", + "HOSTS", + "buildozer.spec", + "hosts", + "pylintrc", + "vlcrc", + ".editorconfig", + ".gitconfig", + ".npmrc" + ] + }, + { + "name": "ProtoBuf", + "extensions": [ + "proto" + ], + "filenames": [] + }, + { + "name": "Pug", + "extensions": [ + "jade", + "pug" + ], + "filenames": [] + }, + { + "name": "Puppet", + "extensions": [ + "pp" + ], + "filenames": [ + "Modulefile" + ] + }, + { + "name": "Python", + "extensions": [ + "py", + "gyp", + "gypi", + "lmi", + "py3", + "pyde", + "pyi", + "pyp", + "pyt", + "pyw", + "rpy", + "tac", + "wsgi", + "xpy" + ], + "filenames": [ + ".gclient", + "DEPS", + "SConscript", + "SConstruct", + "wscript", + "Snakefile" + ] + }, + { + "name": "Q", + "extensions": [ + "q" + ], + "filenames": [] + }, + { + "name": "R", + "extensions": [ + "r", + "rd", + "rhistory", + "rsx" + ], + "filenames": [ + ".Rapp.history", + ".Rhistory", + ".Rprofile", + "expr-dist" + ] + }, + { + "name": "RPM Spec", + "extensions": [ + "spec" + ], + "filenames": [] + }, + { + "name": "Ruby", + "extensions": [ + "rb", + "builder", + "eye", + "gemspec", + "god", + "jbuilder", + "mspec", + "pluginspec", + "podspec", + "prawn", + "rabl", + "rake", + "rbi", + "rbuild", + "rbw", + "rbx", + "ru", + "ruby", + "thor", + "watchr" + ], + "filenames": [ + ".irbrc", + ".pryrc", + ".simplecov", + "Appraisals", + "Berksfile", + "Brewfile", + "Buildfile", + "Capfile", + "Dangerfile", + "Deliverfile", + "Fastfile", + "Gemfile", + "Guardfile", + "Jarfile", + "Mavenfile", + "Podfile", + "Puppetfile", + "Rakefile", + "Snapfile", + "Steepfile", + "Thorfile", + "Vagrantfile", + "buildfile" + ] + }, + { + "name": "Rust", + "extensions": [ + "rs" + ], + "filenames": [] + }, + { + "name": "SAS", + "extensions": [ + "sas" + ], + "filenames": [] + }, + { + "name": "Sass", + "extensions": [ + "sass" + ], + "filenames": [] + }, + { + "name": "Scala", + "extensions": [ + "scala", + "kojo", + "sbt", + "sc" + ], + "filenames": [] + }, + { + "name": "Scheme", + "extensions": [ + "scm", + "sch", + "sld", + "sls", + "sps", + "ss" + ], + "filenames": [] + }, + { + "name": "SCSS", + "extensions": [ + "scss" + ], + "filenames": [] + }, + { + "name": "Shell", + "extensions": [ + "sh", + "bash", + "bats", + "command", + "ksh", + "pacscript", + "sbatch", + "slurm", + "tmux", + "tool", + "trigger", + "zsh", + "zsh-theme" + ], + "filenames": [ + ".bash_aliases", + ".bash_functions", + ".bash_history", + ".bash_logout", + ".bash_profile", + ".bashrc", + ".cshrc", + ".envrc", + ".flaskenv", + ".kshrc", + ".login", + ".profile", + ".tmux.conf", + ".xinitrc", + ".xsession", + ".zlogin", + ".zlogout", + ".zprofile", + ".zshenv", + ".zshrc", + "9fs", + "PKGBUILD", + "bash_aliases", + "bash_logout", + "bash_profile", + "bashrc", + "cshrc", + "gradlew", + "kshrc", + "login", + "man", + "mvnw", + "profile", + "tmux.conf", + "xinitrc", + "xsession", + "zlogin", + "zlogout", + "zprofile", + "zshenv", + "zshrc" + ] + }, + { + "name": "Sieve", + "extensions": [ + "sieve" + ], + "filenames": [] + }, + { + "name": "Smalltalk", + "extensions": [ + "st" + ], + "filenames": [] + }, + { + "name": "SPARQL", + "extensions": [ + "sparql", + "rq" + ], + "filenames": [] + }, + { + "name": "SQL", + "extensions": [ + "sql", + "ddl", + "mysql", + "prc", + "tab", + "udf", + "viw" + ], + "filenames": [] + }, + { + "name": "Squirrel", + "extensions": [ + "nut" + ], + "filenames": [] + }, + { + "name": "Stylus", + "extensions": [ + "styl" + ], + "filenames": [] + }, + { + "name": "Swift", + "extensions": [ + "swift" + ], + "filenames": [] + }, + { + "name": "SystemVerilog", + "extensions": [ + "sv", + "svh", + "vh" + ], + "filenames": [] + }, + { + "name": "Tcl", + "extensions": [ + "tcl", + "adp", + "sdc", + "tm", + "xdc" + ], + "filenames": [ + "owh", + "starfield" + ] + }, + { + "name": "Textile", + "extensions": [ + "textile" + ], + "filenames": [] + }, + { + "name": "TOML", + "extensions": [ + "toml" + ], + "filenames": [ + "Cargo.lock", + "Cargo.toml.orig", + "Gopkg.lock", + "Pipfile", + "mise.local.lock", + "mise.lock", + "pdm.lock", + "poetry.lock", + "uv.lock" + ] + }, + { + "name": "TSX", + "extensions": [ + "tsx" + ], + "filenames": [] + }, + { + "name": "Turtle", + "extensions": [ + "ttl" + ], + "filenames": [] + }, + { + "name": "TypeScript", + "extensions": [ + "ts", + "cts", + "mts" + ], + "filenames": [] + }, + { + "name": "VBScript", + "extensions": [ + "vbs" + ], + "filenames": [] + }, + { + "name": "Verilog", + "extensions": [ + "veo" + ], + "filenames": [] + }, + { + "name": "VHDL", + "extensions": [ + "vhdl", + "vhd", + "vhf", + "vhi", + "vho", + "vhs", + "vht", + "vhw" + ], + "filenames": [] + }, + { + "name": "Vue", + "extensions": [ + "vue" + ], + "filenames": [] + }, + { + "name": "WebAssembly", + "extensions": [ + "wast", + "wat" + ], + "filenames": [] + }, + { + "name": "XML", + "extensions": [ + "xml", + "adml", + "admx", + "ant", + "axaml", + "axml", + "builds", + "ccproj", + "ccxml", + "clixml", + "cproject", + "cscfg", + "csdef", + "csl", + "csproj", + "ct", + "depproj", + "dita", + "ditamap", + "ditaval", + "dotsettings", + "filters", + "fsproj", + "fxml", + "glade", + "gml", + "gmx", + "gpx", + "grxml", + "gst", + "hzp", + "icls", + "iml", + "ivy", + "jelly", + "jsproj", + "kml", + "launch", + "mdpolicy", + "meta4", + "mjml", + "mod", + "mojo", + "mxml", + "natvis", + "ncl", + "ndproj", + "nproj", + "nuspec", + "odd", + "osm", + "pkgproj", + "pluginspec", + "proj", + "props", + "ps1xml", + "psc1", + "pt", + "pubxml", + "qhelp", + "rbxmx", + "rdf", + "res", + "resx", + "rss", + "sch", + "scxml", + "sfproj", + "shproj", + "slnx", + "srdf", + "storyboard", + "sublime-snippet", + "sw", + "targets", + "tml", + "typ", + "ui", + "urdf", + "ux", + "vbproj", + "vcxproj", + "vsixmanifest", + "vssettings", + "vstemplate", + "vxml", + "wixproj", + "workflow", + "wsdl", + "wsf", + "wxi", + "wxl", + "wxs", + "x3d", + "xacro", + "xaml", + "xib", + "xlf", + "xliff", + "xmi", + "xmp", + "xproj", + "xsd", + "xspec", + "xul", + "zcml" + ], + "filenames": [ + ".classpath", + ".cproject", + ".project", + "App.config", + "NuGet.config", + "Settings.StyleCop", + "Web.Debug.config", + "Web.Release.config", + "Web.config", + "packages.config" + ] + }, + { + "name": "XQuery", + "extensions": [ + "xquery", + "xq", + "xql", + "xqm", + "xqy" + ], + "filenames": [] + }, + { + "name": "YAML", + "extensions": [ + "yml", + "mir", + "reek", + "rviz", + "sublime-syntax", + "syntax", + "yaml", + "yaml-tmlanguage" + ], + "filenames": [ + ".clang-format", + ".clang-tidy", + ".clangd", + ".eslintrc", + ".gemrc", + ".releaserc", + "CITATION.cff", + "glide.lock", + "pixi.lock", + "yarn.lock" + ] + } +] diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..9df6b83 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..9df6b83 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/assets/misspellings.csv b/assets/misspellings.csv new file mode 100644 index 0000000..645fb78 --- /dev/null +++ b/assets/misspellings.csv @@ -0,0 +1,21 @@ +acounts,accounts +canidate,candidate +comfirm,confirm +converage,coverage +currrently,currently +delimeter,delimiter +differrent,different +exclusing,excluding +finshed,finished +formated,formatted +inderect,indirect +insuficient,insufficient +likly,likely +mergable,mergeable +overrided,overridden +priortized,prioritized +registeration,registration +reuqest,request +reviwer,reviewer +superceded,superseded +underlaying,underlying diff --git a/build/generate-bindata.go b/build/generate-bindata.go new file mode 100644 index 0000000..3e14eba --- /dev/null +++ b/build/generate-bindata.go @@ -0,0 +1,27 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "fmt" + "os" + + "gitea.dev/modules/assetfs" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("usage: ./generate-bindata {local-directory} {bindata-filename}") + os.Exit(1) + } + + dir, filename := os.Args[1], os.Args[2] + fmt.Printf("generating bindata for %s to %s\n", dir, filename) + if err := assetfs.GenerateEmbedBindata(dir, filename); err != nil { + fmt.Printf("failed: %s\n", err.Error()) + os.Exit(1) + } +} diff --git a/build/generate-emoji.go b/build/generate-emoji.go new file mode 100644 index 0000000..7d38a4c --- /dev/null +++ b/build/generate-emoji.go @@ -0,0 +1,219 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright 2015 Kenneth Shaw +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "flag" + "fmt" + "go/format" + "io" + "log" + "net/http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "gitea.dev/modules/json" +) + +const ( + gemojiURL = "https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json" + maxUnicodeVersion = 16 +) + +var flagOut = flag.String("o", "modules/emoji/emoji_data.go", "out") + +// Gemoji is a set of emoji data. +type Gemoji []Emoji + +// Emoji represents a single emoji and associated data. +type Emoji struct { + Emoji string `json:"emoji"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases"` + UnicodeVersion string `json:"unicode_version,omitempty"` + SkinTones bool `json:"skin_tones,omitempty"` +} + +// Don't include some fields in JSON +func (e Emoji) MarshalJSON() ([]byte, error) { + type emoji Emoji + x := emoji(e) + x.UnicodeVersion = "" + x.Description = "" + x.SkinTones = false + return json.Marshal(x) +} + +func main() { + flag.Parse() + + // generate data + buf, err := generate() + if err != nil { + log.Fatalf("generate err: %v", err) + } + + // write + err = os.WriteFile(*flagOut, buf, 0o644) + if err != nil { + log.Fatalf("WriteFile err: %v", err) + } +} + +var replacer = strings.NewReplacer( + "main.Gemoji", "Gemoji", + "main.Emoji", "\n", + "}}", "},\n}", + ", Description:", ", ", + ", Aliases:", ", ", + ", UnicodeVersion:", ", ", + ", SkinTones:", ", ", +) + +var emojiRE = regexp.MustCompile(`\{Emoji:"([^"]*)"`) + +func generate() ([]byte, error) { + // load gemoji data + res, err := http.Get(gemojiURL) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // read all + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + // unmarshal + var data Gemoji + err = json.Unmarshal(body, &data) + if err != nil { + return nil, err + } + + skinTones := make(map[string]string) + + skinTones["\U0001f3fb"] = "Light Skin Tone" + skinTones["\U0001f3fc"] = "Medium-Light Skin Tone" + skinTones["\U0001f3fd"] = "Medium Skin Tone" + skinTones["\U0001f3fe"] = "Medium-Dark Skin Tone" + skinTones["\U0001f3ff"] = "Dark Skin Tone" + + var tmp Gemoji + + // filter out emoji that require greater than max unicode version + for i := range data { + val, _ := strconv.ParseFloat(data[i].UnicodeVersion, 64) + if int(val) <= maxUnicodeVersion { + tmp = append(tmp, data[i]) + } + } + data = tmp + + sort.Slice(data, func(i, j int) bool { + return data[i].Aliases[0] < data[j].Aliases[0] + }) + + aliasMap := make(map[string]int, len(data)) + + for i, e := range data { + if e.Emoji == "" || len(e.Aliases) == 0 { + continue + } + for _, a := range e.Aliases { + if a == "" { + continue + } + aliasMap[a] = i + } + } + + // gitea customizations + i, ok := aliasMap["tada"] + if ok { + data[i].Aliases = append(data[i].Aliases, "hooray") + } + i, ok = aliasMap["laughing"] + if ok { + data[i].Aliases = append(data[i].Aliases, "laugh") + } + + // write a JSON file to use with tribute (write before adding skin tones since we can't support them there yet) + file, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("assets/emoji.json", append(file, '\n'), 0o644) + + // Add skin tones to emoji that support it + var ( + s []string + newEmoji string + newDescription string + newData Emoji + ) + + for i := range data { + if data[i].SkinTones { + for k, v := range skinTones { + s = strings.Split(data[i].Emoji, "") + + if utf8.RuneCountInString(data[i].Emoji) == 1 { + s = append(s, k) + } else { + // insert into slice after first element because all emoji that support skin tones + // have that modifier placed at this spot + s = append(s, "") + copy(s[2:], s[1:]) + s[1] = k + } + + newEmoji = strings.Join(s, "") + newDescription = data[i].Description + ": " + v + newAlias := data[i].Aliases[0] + "_" + strings.ReplaceAll(v, " ", "_") + + newData = Emoji{newEmoji, newDescription, []string{newAlias}, "12.0", false} + data = append(data, newData) + } + } + } + + sort.Slice(data, func(i, j int) bool { + return data[i].Aliases[0] < data[j].Aliases[0] + }) + + // add header + str := replacer.Replace(fmt.Sprintf(hdr, gemojiURL, data)) + + // change the format of the unicode string + str = emojiRE.ReplaceAllStringFunc(str, func(s string) string { + var err error + s, err = strconv.Unquote(s[len("{Emoji:"):]) + if err != nil { + panic(err) + } + return "{" + strconv.QuoteToASCII(s) + }) + + // format + return format.Source([]byte(str)) +} + +const hdr = ` +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + + +package emoji + +// Code generated by build/generate-emoji.go. DO NOT EDIT. +// Sourced from %s +var GemojiData = %#v +` diff --git a/build/generate-gitignores.go b/build/generate-gitignores.go new file mode 100644 index 0000000..11af4eb --- /dev/null +++ b/build/generate-gitignores.go @@ -0,0 +1,129 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "archive/tar" + "compress/gzip" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "gitea.dev/modules/util" +) + +func main() { + var ( + prefix = "gitea-gitignore" + url = "https://api.github.com/repos/github/gitignore/tarball" + githubApiToken = "" + githubUsername = "" + destination = "" + ) + + flag.StringVar(&destination, "dest", "options/gitignore/", "destination for the gitignores") + flag.StringVar(&githubUsername, "username", "", "github username") + flag.StringVar(&githubApiToken, "token", "", "github api token") + flag.Parse() + + file, err := os.CreateTemp(os.TempDir(), prefix) + if err != nil { + log.Fatalf("Failed to create temp file. %s", err) + } + + defer util.Remove(file.Name()) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + log.Fatalf("Failed to download archive. %s", err) + } + + if len(githubApiToken) > 0 && len(githubUsername) > 0 { + req.SetBasicAuth(githubUsername, githubApiToken) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("Failed to download archive. %s", err) + } + defer resp.Body.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + log.Fatalf("Failed to copy archive to file. %s", err) + } + + if _, err := file.Seek(0, 0); err != nil { + log.Fatalf("Failed to reset seek on archive. %s", err) + } + + gz, err := gzip.NewReader(file) + if err != nil { + log.Fatalf("Failed to gunzip the archive. %s", err) + } + + tr := tar.NewReader(gz) + + filesToCopy := make(map[string]string, 0) + + for { + hdr, err := tr.Next() + + if err == io.EOF { + break + } + + if err != nil { + log.Fatalf("Failed to iterate archive. %s", err) + } + + if filepath.Ext(hdr.Name) != ".gitignore" { + continue + } + + if hdr.Typeflag == tar.TypeSymlink { + fmt.Printf("Found symlink %s -> %s\n", hdr.Name, hdr.Linkname) + filesToCopy[strings.TrimSuffix(filepath.Base(hdr.Name), ".gitignore")] = strings.TrimSuffix(filepath.Base(hdr.Linkname), ".gitignore") + continue + } + + out, err := os.Create(path.Join(destination, strings.TrimSuffix(filepath.Base(hdr.Name), ".gitignore"))) + if err != nil { + log.Fatalf("Failed to create new file. %s", err) + } + + defer out.Close() + + if _, err := io.Copy(out, tr); err != nil { + log.Fatalf("Failed to write new file. %s", err) + } else { + fmt.Printf("Written %s\n", out.Name()) + } + } + + for dst, src := range filesToCopy { + // Read all content of src to data + src = path.Join(destination, src) + data, err := os.ReadFile(src) + if err != nil { + log.Fatalf("Failed to read src file. %s", err) + } + // Write data to dst + dst = path.Join(destination, dst) + err = os.WriteFile(dst, data, 0o644) + if err != nil { + log.Fatalf("Failed to write new file. %s", err) + } + fmt.Printf("Written (copy of %s) %s\n", src, dst) + } + + fmt.Println("Done") +} diff --git a/build/generate-go-licenses.go b/build/generate-go-licenses.go new file mode 100644 index 0000000..a4aa5b0 --- /dev/null +++ b/build/generate-go-licenses.go @@ -0,0 +1,239 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" +) + +// regexp is based on go-license, excluding README and NOTICE +// https://github.com/google/go-licenses/blob/master/licenses/find.go +// also defined in vite.config.ts +var licenseRe = regexp.MustCompile(`^(?i)((UN)?LICEN(S|C)E|COPYING).*$`) + +// primaryLicenseRe matches exact primary license filenames without suffixes. +// When a directory has both primary and variant files (e.g. LICENSE and +// LICENSE.docs), only the primary files are kept. +var primaryLicenseRe = regexp.MustCompile(`^(?i)(LICEN[SC]E|COPYING)$`) + +// ignoredNames are LicenseEntry.Name values to exclude from the output. +var ignoredNames = map[string]bool{ + "gitea.dev": true, + "gitea.dev/options/license": true, +} + +var excludedExt = map[string]bool{ + ".gitignore": true, + ".go": true, + ".mod": true, + ".sum": true, + ".toml": true, + ".yaml": true, + ".yml": true, +} + +type ModuleInfo struct { + Path string + Dir string + PkgDirs []string // directories of packages imported from this module +} + +type LicenseEntry struct { + Name string `json:"name"` + Path string `json:"path"` + LicenseText string `json:"licenseText"` +} + +// getModules returns all dependency modules with their local directory paths +// and the package directories used from each module. +func getModules(goCmd string) []ModuleInfo { + cmd := exec.Command(goCmd, "list", "-deps", "-f", + "{{if .Module}}{{.Module.Path}}\t{{.Module.Dir}}\t{{.Dir}}{{end}}", "./...") + cmd.Stderr = os.Stderr + // Use GOOS=linux with CGO to ensure we capture all platform-specific + // dependencies, matching the CI environment. + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1") + output, err := cmd.Output() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to run 'go list -deps': %v\n", err) + os.Exit(1) + } + + var modules []ModuleInfo + seen := make(map[string]int) // module path -> index in modules + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) != 3 { + continue + } + modPath, modDir, pkgDir := parts[0], parts[1], parts[2] + if idx, ok := seen[modPath]; ok { + modules[idx].PkgDirs = append(modules[idx].PkgDirs, pkgDir) + } else { + seen[modPath] = len(modules) + modules = append(modules, ModuleInfo{ + Path: modPath, + Dir: modDir, + PkgDirs: []string{pkgDir}, + }) + } + } + return modules +} + +// findLicenseFiles scans a module's root directory and its used package +// directories for license files. It also walks up from each package directory +// to the module root, scanning intermediate directories. Subdirectory licenses +// are only included if their text differs from the root license(s). +func findLicenseFiles(mod ModuleInfo) []LicenseEntry { + var entries []LicenseEntry + seenTexts := make(map[string]bool) + + // First, collect root-level license files. + entries = append(entries, scanDirForLicenses(mod.Dir, mod.Path, "")...) + for _, e := range entries { + seenTexts[e.LicenseText] = true + } + + // Then check each package directory and all intermediate parent directories + // up to the module root for license files with unique text. + seenDirs := map[string]bool{mod.Dir: true} + for _, pkgDir := range mod.PkgDirs { + for dir := pkgDir; dir != mod.Dir && strings.HasPrefix(dir, mod.Dir); dir = filepath.Dir(dir) { + if seenDirs[dir] { + continue + } + seenDirs[dir] = true + for _, e := range scanDirForLicenses(dir, mod.Path, mod.Dir) { + if !seenTexts[e.LicenseText] { + seenTexts[e.LicenseText] = true + entries = append(entries, e) + } + } + } + } + return entries +} + +// scanDirForLicenses reads a single directory for license files and returns entries. +// If moduleRoot is non-empty, paths are made relative to it. +func scanDirForLicenses(dir, modulePath, moduleRoot string) []LicenseEntry { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var entries []LicenseEntry + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !licenseRe.MatchString(name) { + continue + } + if excludedExt[strings.ToLower(filepath.Ext(name))] { + continue + } + + content, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + + entryName := modulePath + entryPath := modulePath + "/" + name + if moduleRoot != "" { + rel, _ := filepath.Rel(moduleRoot, dir) + if rel != "." { + relSlash := filepath.ToSlash(rel) + entryName = modulePath + "/" + relSlash + entryPath = modulePath + "/" + relSlash + "/" + name + } + } + + entries = append(entries, LicenseEntry{ + Name: entryName, + Path: entryPath, + LicenseText: string(content), + }) + } + + // When multiple license files exist, prefer primary files (e.g. LICENSE) + // over variants with suffixes (e.g. LICENSE.docs, LICENSE-2.0.txt). + // If no primary file exists, keep only the first variant. + if len(entries) > 1 { + var primary []LicenseEntry + for _, e := range entries { + fileName := e.Path[strings.LastIndex(e.Path, "/")+1:] + if primaryLicenseRe.MatchString(fileName) { + primary = append(primary, e) + } + } + if len(primary) > 0 { + return primary + } + return entries[:1] + } + + return entries +} + +func main() { + if len(os.Args) != 2 { + fmt.Println("usage: go run generate-go-licenses.go ") + os.Exit(1) + } + + out := os.Args[1] + + goCmd := "go" + if env := os.Getenv("GO"); env != "" { + goCmd = env + } + + modules := getModules(goCmd) + + var entries []LicenseEntry + for _, mod := range modules { + entries = append(entries, findLicenseFiles(mod)...) + } + + entries = slices.DeleteFunc(entries, func(e LicenseEntry) bool { + return ignoredNames[e.Name] + }) + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + + jsonBytes, err := json.MarshalIndent(entries, "", " ") + if err != nil { + panic(err) + } + + // Ensure file has a final newline + if jsonBytes[len(jsonBytes)-1] != '\n' { + jsonBytes = append(jsonBytes, '\n') + } + + err = os.WriteFile(out, jsonBytes, 0o644) + if err != nil { + panic(err) + } +} diff --git a/build/generate-openapi.go b/build/generate-openapi.go new file mode 100644 index 0000000..dd73043 --- /dev/null +++ b/build/generate-openapi.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// generate-openapi converts Gitea's Swagger 2.0 spec into an OpenAPI 3.0 spec. +// +// Gitea generates a Swagger 2.0 spec from code annotations (make generate-swagger). +// This tool converts it to OAS3 so that SDK generators and tools that require +// OAS3 (e.g. progenitor for Rust) can consume it directly. The conversion also +// deduplicates inline enum definitions into named schema components, producing +// cleaner SDK output with proper enum types instead of anonymous strings. +// +// Run: go run build/generate-openapi.go +// Output: templates/swagger/v1-openapi3.generated.json + +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" + "strings" + + "gitea.dev/build/openapi3gen" +) + +const ( + swaggerSpecPath = "templates/swagger/v1-swagger.generated.json" + openapi3OutPath = "templates/swagger/v1-openapi3.generated.json" +) + +var enumScanDirs = []string{ + "modules/structs", + "modules/commitstatus", +} + +func main() { + astEnumMap, err := openapi3gen.ScanSwaggerEnumTypes(enumScanDirs) + if err != nil { + log.Fatalf("scanning swagger:enum annotations: %v", err) + } + names := make([]string, 0, len(astEnumMap)) + for _, ns := range astEnumMap { + names = append(names, ns...) + } + sort.Strings(names) + fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", ")) + + data, err := os.ReadFile(swaggerSpecPath) + if err != nil { + log.Fatalf("reading swagger spec: %v", err) + } + + oas3, err := openapi3gen.Convert(data, astEnumMap) + if err != nil { + log.Fatalf("converting to openapi 3.0: %v", err) + } + + out, err := json.MarshalIndent(oas3, "", " ") + if err != nil { + log.Fatalf("marshaling openapi 3.0: %v", err) + } + + if err := os.WriteFile(openapi3OutPath, out, 0o644); err != nil { + log.Fatalf("writing openapi 3.0 spec: %v", err) + } + + fmt.Printf("Generated %s\n", openapi3OutPath) +} diff --git a/build/openapi3gen/convert.go b/build/openapi3gen/convert.go new file mode 100644 index 0000000..922e055 --- /dev/null +++ b/build/openapi3gen/convert.go @@ -0,0 +1,387 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "gitea.dev/modules/json" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +// rxDeprecated matches "deprecated" as a word at the start of a description +// or preceded by whitespace/punctuation that indicates a leading marker (e.g. +// "Deprecated: true", "deprecated (use X instead)"). Rejects negated phrases +// like "not deprecated" or "previously deprecated, now supported". +var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`) + +// Convert parses a Swagger 2.0 spec and returns an OAS3 spec, applying +// Gitea-specific post-processing: server URL, file-schema fixups, URI +// formats, deprecated flags, and shared-enum extraction. +// +// astEnumMap is a value-set-key → Go-type-name(s) map (built by +// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types, +// per-property disambiguation uses the x-go-enum-desc extension. If a shared +// enum in the spec has no matching entry, Convert returns an error — no +// fallback naming. +func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) { + var swagger2 openapi2.T + if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil { + return nil, fmt.Errorf("parsing swagger 2.0: %w", err) + } + + oas3, err := openapi2conv.ToV3(&swagger2) + if err != nil { + return nil, fmt.Errorf("converting to openapi 3.0: %w", err) + } + + oas3.Servers = openapi3.Servers{{URL: swagger2.BasePath}} + fixFileSchemas(oas3) + addURIFormats(oas3) + addDeprecatedFlags(oas3) + if err := extractSharedEnums(oas3, astEnumMap); err != nil { + return nil, err + } + return oas3, nil +} + +func fixFileSchemas(doc *openapi3.T) { + for _, pathItem := range doc.Paths.Map() { + for _, op := range []*openapi3.Operation{ + pathItem.Get, pathItem.Post, pathItem.Put, pathItem.Patch, + pathItem.Delete, pathItem.Head, pathItem.Options, pathItem.Trace, + } { + if op == nil { + continue + } + for _, resp := range op.Responses.Map() { + if resp.Value == nil { + continue + } + for _, mediaType := range resp.Value.Content { + fixSchema(mediaType.Schema) + } + } + if op.RequestBody != nil && op.RequestBody.Value != nil { + for _, mediaType := range op.RequestBody.Value.Content { + fixSchema(mediaType.Schema) + } + } + } + } +} + +// fixSchema rewrites any "type: file" schemas to the OAS3 equivalent +// (type: string, format: binary), recursing into Properties, Items, and +// AllOf/OneOf/AnyOf/Not branches. $ref nodes are skipped so shared schemas +// are rewritten exactly once when visited through their declaration. +func fixSchema(ref *openapi3.SchemaRef) { + if ref == nil || ref.Value == nil || ref.Ref != "" { + return + } + s := ref.Value + if s.Type.Is("file") { + s.Type = &openapi3.Types{"string"} + s.Format = "binary" + } + for _, p := range s.Properties { + fixSchema(p) + } + fixSchema(s.Items) + for _, sub := range s.AllOf { + fixSchema(sub) + } + for _, sub := range s.OneOf { + fixSchema(sub) + } + for _, sub := range s.AnyOf { + fixSchema(sub) + } + fixSchema(s.Not) +} + +// addURIFormats sets format: uri on string properties whose names indicate +// they hold URLs. This information is lost in Swagger 2.0 but is valuable +// for code generators. +func addURIFormats(doc *openapi3.T) { + if doc.Components == nil { + return + } + for _, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for propName, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + prop := propRef.Value + if !prop.Type.Is("string") || prop.Format != "" { + continue + } + if isURLProperty(propName) { + prop.Format = "uri" + } + } + } +} + +func isURLProperty(name string) bool { + if strings.HasSuffix(name, "_url") { + return true + } + switch name { + case "url", "html_url", "clone_url": + return true + } + return false +} + +// addDeprecatedFlags sets deprecated: true on schema properties whose +// description starts with a "deprecated" marker (e.g. "Deprecated: true" +// or "deprecated (use X instead)"). Does not match negated phrases. +func addDeprecatedFlags(doc *openapi3.T) { + if doc.Components == nil { + return + } + for _, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for _, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + if rxDeprecated.MatchString(propRef.Value.Description) { + propRef.Value.Deprecated = true + } + } + } +} + +type enumUsage struct { + schemaName string + propName string + propRef *openapi3.SchemaRef + inItems bool +} + +// extractSharedEnums finds identical enum arrays used by multiple schema +// properties, creates a standalone named schema for each, and replaces +// the inline enums with $ref pointers. +// +// If the derived enum name collides with an existing component schema, or +// no // swagger:enum annotation matches the value set, generation aborts +// with an actionable error — there are no silent fallbacks. +func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error { + if doc.Components == nil { + return nil + } + + type groupKey struct { + valueSet string + typeName string + } + enumGroups := map[groupKey][]enumUsage{} + groupOrder := []groupKey{} // deterministic iteration + + addUsage := func(key groupKey, u enumUsage) { + if _, seen := enumGroups[key]; !seen { + groupOrder = append(groupOrder, key) + } + enumGroups[key] = append(enumGroups[key], u) + } + + for schemaName, schemaRef := range doc.Components.Schemas { + if schemaRef.Value == nil { + continue + } + for propName, propRef := range schemaRef.Value.Properties { + if propRef == nil || propRef.Value == nil || propRef.Ref != "" { + continue + } + if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") { + key := groupKey{ + valueSet: EnumKey(propRef.Value.Enum), + typeName: extractEnumTypeName(propRef.Value, astEnumMap), + } + addUsage(key, enumUsage{schemaName, propName, propRef, false}) + } + if propRef.Value.Type.Is("array") && propRef.Value.Items != nil && + propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" && + len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") { + key := groupKey{ + valueSet: EnumKey(propRef.Value.Items.Value.Enum), + typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap), + } + addUsage(key, enumUsage{schemaName, propName, propRef, true}) + } + } + } + + for _, key := range groupOrder { + usages := enumGroups[key] + if len(usages) < 2 { + continue + } + + enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap) + if err != nil { + return err + } + if _, exists := doc.Components.Schemas[enumName]; exists { + return fmt.Errorf("enum name collision: %s already exists as a component schema", enumName) + } + + var enumValues []any + if usages[0].inItems { + enumValues = usages[0].propRef.Value.Items.Value.Enum + } else { + enumValues = usages[0].propRef.Value.Enum + } + + doc.Components.Schemas[enumName] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enumValues, + }, + } + + ref := "#/components/schemas/" + enumName + + for _, usage := range usages { + if usage.inItems { + usage.propRef.Value.Items = &openapi3.SchemaRef{Ref: ref} + } else { + old := usage.propRef.Value + if old.Description == "" && !old.Deprecated && old.Format == "" { + usage.propRef.Ref = ref + usage.propRef.Value = nil + } else { + usage.propRef.Value = &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{ + {Ref: ref}, + }, + Description: old.Description, + Deprecated: old.Deprecated, + Format: old.Format, + } + } + } + } + } + return nil +} + +// deriveEnumName looks up a shared enum's Go type name. If typeName is +// non-empty (because we recovered it from x-go-enum-desc), it is used +// directly. Otherwise the value-set must map to exactly one known type. On +// failure, returns an error identifying the offending properties. +func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) { + if typeName != "" { + return typeName, nil + } + names := astEnumMap[key] + if len(names) == 1 { + return names[0], nil + } + + props := map[string]bool{} + for _, u := range usages { + props[fmt.Sprintf("%s.%s", u.schemaName, u.propName)] = true + } + propList := make([]string, 0, len(props)) + for p := range props { + propList = append(propList, p) + } + if len(names) > 1 { + return "", fmt.Errorf( + "value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+ + "ensure go-swagger emits x-go-enum-desc for those properties", + key, names, propList, + ) + } + return "", fmt.Errorf( + "no swagger:enum annotation matches value-set %q used by %d properties: %v; "+ + "fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus", + key, len(usages), propList, + ) +} + +// extractEnumTypeName recovers the Go type name a schema's enum came from by +// parsing the property's x-go-enum-desc extension. go-swagger emits one line +// per value as " [ ]"; the type is the longest +// common prefix of the const names, narrowed to the candidate set in +// astEnumMap. Returns "" if extraction is inconclusive. +func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string { + if s == nil || s.Extensions == nil { + return "" + } + raw, ok := s.Extensions["x-go-enum-desc"] + if !ok { + return "" + } + desc, ok := raw.(string) + if !ok { + return "" + } + candidates := astEnumMap[EnumKey(s.Enum)] + if len(candidates) == 0 { + return "" + } + // Collect the const names (second whitespace-separated field per line). + var consts []string + for line := range strings.SplitSeq(desc, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + consts = append(consts, fields[1]) + } + } + if len(consts) == 0 { + return "" + } + // A candidate matches when it is a prefix of every const name AND the + // first character after the prefix is an uppercase ASCII letter — this + // rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower) + // while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX" + // (both prefixes valid). The most specific (longest) wins; ties return + // "" so deriveEnumName surfaces the ambiguity rather than silently + // picking a winner. + ordered := append([]string(nil), candidates...) + sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) }) + var matches []string + for _, name := range ordered { + ok := true + for _, c := range consts { + if !strings.HasPrefix(c, name) { + ok = false + break + } + suffix := c[len(name):] + // Empty suffix means the const name exactly equals the type name — valid exact match. + // A non-empty suffix must begin with an uppercase letter to reject incidental + // prefix matches (e.g. "Alpha" should not match "Alphabet"). + if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') { + ok = false + break + } + } + if ok { + matches = append(matches, name) + } + } + if len(matches) == 0 { + return "" + } + if len(matches) > 1 && len(matches[0]) == len(matches[1]) { + return "" + } + return matches[0] +} diff --git a/build/openapi3gen/convert_test.go b/build/openapi3gen/convert_test.go new file mode 100644 index 0000000..1cf73bd --- /dev/null +++ b/build/openapi3gen/convert_test.go @@ -0,0 +1,218 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestDeriveEnumName_hit(t *testing.T) { + key := EnumKey([]any{"red", "green", "blue"}) + astMap := map[string][]string{key: {"Color"}} + usages := []enumUsage{{schemaName: "Paint", propName: "color"}} + got, err := deriveEnumName(key, "", usages, astMap) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Color" { + t.Fatalf("got %q, want %q", got, "Color") + } +} + +func TestDeriveEnumName_miss(t *testing.T) { + key := EnumKey([]any{"x", "y"}) + usages := []enumUsage{{schemaName: "Thing", propName: "kind"}} + _, err := deriveEnumName(key, "", usages, map[string][]string{}) + if err == nil { + t.Fatal("expected miss error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "Thing.kind") { + t.Fatalf("error %q should list the missing usage", msg) + } + if !strings.Contains(msg, "swagger:enum") { + t.Fatalf("error %q should hint at the fix", msg) + } +} + +func TestExtractSharedEnums_usesASTMap(t *testing.T) { + doc := &openapi3.T{ + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "A": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green", "blue"}, + }}, + }, + }}, + "B": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green", "blue"}, + }}, + }, + }}, + }, + }, + } + astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}} + if err := extractSharedEnums(doc, astMap); err != nil { + t.Fatalf("extractSharedEnums: %v", err) + } + if _, ok := doc.Components.Schemas["Color"]; !ok { + t.Fatalf("expected Color schema to be extracted") + } +} + +func TestFixFileSchemas_recursesIntoNested(t *testing.T) { + fileType := func() *openapi3.SchemaRef { + return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"file"}}} + } + doc := &openapi3.T{ + Paths: openapi3.NewPaths(), + } + doc.Paths.Set("/upload", &openapi3.PathItem{ + Post: &openapi3.Operation{ + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "multipart/form-data": { + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "attachment": fileType(), + "items": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: fileType(), + }}, + "alt": {Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{fileType()}, + }}, + "one": {Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{fileType()}, + }}, + "any": {Value: &openapi3.Schema{ + AnyOf: openapi3.SchemaRefs{fileType()}, + }}, + "not": {Value: &openapi3.Schema{ + Not: fileType(), + }}, + }, + }}, + }, + }, + }, + }, + Responses: openapi3.NewResponses(), + }, + }) + + fixFileSchemas(doc) + + props := doc.Paths.Value("/upload").Post.RequestBody.Value.Content["multipart/form-data"].Schema.Value.Properties + if !props["attachment"].Value.Type.Is("string") || props["attachment"].Value.Format != "binary" { + t.Errorf("nested property not fixed: %+v", props["attachment"].Value) + } + if !props["items"].Value.Items.Value.Type.Is("string") || props["items"].Value.Items.Value.Format != "binary" { + t.Errorf("array items not fixed: %+v", props["items"].Value.Items.Value) + } + if !props["alt"].Value.AllOf[0].Value.Type.Is("string") || props["alt"].Value.AllOf[0].Value.Format != "binary" { + t.Errorf("allOf branch not fixed: %+v", props["alt"].Value.AllOf[0].Value) + } + if !props["one"].Value.OneOf[0].Value.Type.Is("string") || props["one"].Value.OneOf[0].Value.Format != "binary" { + t.Errorf("oneOf branch not fixed: %+v", props["one"].Value.OneOf[0].Value) + } + if !props["any"].Value.AnyOf[0].Value.Type.Is("string") || props["any"].Value.AnyOf[0].Value.Format != "binary" { + t.Errorf("anyOf branch not fixed: %+v", props["any"].Value.AnyOf[0].Value) + } + if !props["not"].Value.Not.Value.Type.Is("string") || props["not"].Value.Not.Value.Format != "binary" { + t.Errorf("not branch not fixed: %+v", props["not"].Value.Not.Value) + } +} + +func TestExtractEnumTypeName_TeamVisibility(t *testing.T) { + enum := []any{"public", "limited", "private"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" { + t.Fatalf("got %q, want %q", got, "TeamVisibility") + } +} + +func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) { + enum := []any{"one", "two"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"AB", "AC"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "one ABOne\ntwo ACTwo", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "" { + t.Fatalf("got %q, want empty string for ambiguous tie", got) + } +} + +func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) { + enum := []any{"a", "b"} + key := EnumKey(enum) + astMap := map[string][]string{key: {"Alpha", "Alphabet"}} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: enum, + Extensions: map[string]any{ + "x-go-enum-desc": "a AlphabetA\nb AlphabetB", + }, + } + if got := extractEnumTypeName(schema, astMap); got != "Alphabet" { + t.Fatalf("got %q, want %q", got, "Alphabet") + } +} + +func TestExtractSharedEnums_missReturnsError(t *testing.T) { + doc := &openapi3.T{ + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "A": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green"}, + }}, + }, + }}, + "B": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "color": {Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"red", "green"}, + }}, + }, + }}, + }, + }, + } + if err := extractSharedEnums(doc, map[string][]string{}); err == nil { + t.Fatal("expected miss error") + } +} diff --git a/build/openapi3gen/enumscan.go b/build/openapi3gen/enumscan.go new file mode 100644 index 0000000..73bbcb5 --- /dev/null +++ b/build/openapi3gen/enumscan.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package openapi3gen converts Gitea's Swagger 2.0 spec to an OpenAPI 3.0 +// spec. It discovers Go enum type names by scanning swagger:enum annotations +// in the source tree, then names extracted shared-enum schemas accordingly. +package openapi3gen + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// EnumKey returns a canonical key for a set of enum values: values are +// stringified, sorted, and joined with "|". Used to match enum value sets +// across spec properties and scanned Go type declarations. +func EnumKey(values []any) string { + strs := make([]string, len(values)) + for i, v := range values { + strs[i] = fmt.Sprintf("%v", v) + } + sort.Strings(strs) + return strings.Join(strs, "|") +} + +var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`) + +// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from +// a canonical value-set key (see EnumKey) to the Go type names declared with +// // swagger:enum TypeName. Multiple type names per key are allowed (e.g. +// distinct enum types that happen to share a value set such as +// {public, limited, private}); callers must disambiguate per-usage (typically +// by parsing the property's x-go-enum-desc extension to recover the const +// type prefix). +// +// Returns an error on parse failure or on an annotation for a type whose +// constants can't be extracted. +func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) { + fset := token.NewFileSet() + parsed := []*ast.File{} + + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + if strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + path := filepath.Join(dir, entry.Name()) + file, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + parsed = append(parsed, file) + } + } + + enumTypes := map[string]string{} // typeName → "" (presence marker) + enumValues := map[string][]any{} // typeName → values + + // Pass 1: collect every // swagger:enum TypeName declaration. + for _, file := range parsed { + for _, decl := range file.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + if err := collectEnumType(gd, enumTypes); err != nil { + return nil, fmt.Errorf("%s: %w", fset.Position(gd.Pos()).Filename, err) + } + } + } + + // Pass 2: collect const values; now every annotated type is visible. + for _, file := range parsed { + for _, decl := range file.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + collectEnumValues(gd, enumTypes, enumValues) + } + } + + result := map[string][]string{} + for typeName := range enumTypes { + values, ok := enumValues[typeName] + if !ok || len(values) == 0 { + return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName) + } + key := EnumKey(values) + result[key] = append(result[key], typeName) + } + for key, names := range result { + sort.Strings(names) + result[key] = names + } + return result, nil +} + +// collectEnumType scans a `type` GenDecl for // swagger:enum annotations, +// handling both the lone form (`// swagger:enum Foo\n type Foo string`) +// where the comment group is attached to the GenDecl, and the grouped form: +// +// type ( +// // swagger:enum Foo +// Foo string +// ) +// +// where the comment group is attached to each TypeSpec. Caveat: Go's parser +// only attaches a CommentGroup when it is immediately adjacent to the decl. +// A blank line (not a `//` continuation line) between the comment and the +// declaration drops the Doc, so annotations MUST sit directly above their +// type. All current annotated files obey this — the rule is noted here so +// a future edit that inserts a blank line fails fast rather than silently. +func collectEnumType(gd *ast.GenDecl, enumTypes map[string]string) error { + if err := registerEnumAnnotation(gd.Doc, gd.Specs, enumTypes); err != nil { + return err + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Doc == nil { + continue + } + if err := registerEnumAnnotation(ts.Doc, []ast.Spec{ts}, enumTypes); err != nil { + return err + } + } + return nil +} + +func registerEnumAnnotation(doc *ast.CommentGroup, specs []ast.Spec, enumTypes map[string]string) error { + if doc == nil { + return nil + } + matches := rxSwaggerEnum.FindStringSubmatch(doc.Text()) + if len(matches) < 2 { + return nil + } + annotated := matches[1] + for _, spec := range specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if ts.Name.Name == annotated { + enumTypes[annotated] = "" + return nil + } + } + return fmt.Errorf("swagger:enum %s: no type declaration with that name in the same decl group; check for a typo", annotated) +} + +func collectEnumValues(gd *ast.GenDecl, enumTypes map[string]string, enumValues map[string][]any) { + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || vs.Type == nil { + continue + } + ident, ok := vs.Type.(*ast.Ident) + if !ok { + continue + } + if _, isEnum := enumTypes[ident.Name]; !isEnum { + continue + } + for _, val := range vs.Values { + lit, ok := val.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + unquoted, err := strconv.Unquote(lit.Value) + if err != nil { + continue + } + enumValues[ident.Name] = append(enumValues[ident.Name], unquoted) + } + } +} diff --git a/build/openapi3gen/enumscan_test.go b/build/openapi3gen/enumscan_test.go new file mode 100644 index 0000000..8cb16b7 --- /dev/null +++ b/build/openapi3gen/enumscan_test.go @@ -0,0 +1,249 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package openapi3gen + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func single(got map[string][]string, key string) string { + v := got[key] + if len(v) != 1 { + return "" + } + return v[0] +} + +func TestEnumKey_sortsAndJoins(t *testing.T) { + key := EnumKey([]any{"b", "a", "c"}) + if key != "a|b|c" { + t.Fatalf("EnumKey = %q, want %q", key, "a|b|c") + } +} + +func TestEnumKey_handlesNonStringValues(t *testing.T) { + key := EnumKey([]any{2, 1, 3}) + if key != "1|2|3" { + t.Fatalf("EnumKey = %q, want %q", key, "1|2|3") + } +} + +func TestScanSwaggerEnumTypes_basic(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// Color is a primary color. +// swagger:enum Color +type Color string + +const ( + ColorRed Color = "red" + ColorGreen Color = "green" + ColorBlue Color = "blue" +) +` + if err := os.WriteFile(filepath.Join(dir, "color.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"red", "green", "blue"}) + if single(got, wantKey) != "Color" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color") + } +} + +func TestScanSwaggerEnumTypes_orphanAnnotation(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Sttype +type StateType string + +const ( + StateOpen StateType = "open" +) +` + if err := os.WriteFile(filepath.Join(dir, "typo.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected error for annotation referencing a non-matching type name") + } + if !strings.Contains(err.Error(), "Sttype") { + t.Fatalf("error %q should mention the typo'd name Sttype", err.Error()) + } +} + +func TestScanSwaggerEnumTypes_collision(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Alpha +type Alpha string +const ( + AlphaX Alpha = "x" + AlphaY Alpha = "y" +) + +// swagger:enum Beta +type Beta string +const ( + BetaX Beta = "x" + BetaY Beta = "y" +) +` + if err := os.WriteFile(filepath.Join(dir, "dup.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + key := EnumKey([]any{"x", "y"}) + names := got[key] + if !slices.Equal(names, []string{"Alpha", "Beta"}) { + t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names) + } +} + +func TestScanSwaggerEnumTypes_parseFailure(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte("package fixture\nfunc Foo() {"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected parse error, got nil") + } +} + +func TestScanSwaggerEnumTypes_annotationWithoutConsts(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +// swagger:enum Lonely +type Lonely string +` + if err := os.WriteFile(filepath.Join(dir, "lonely.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ScanSwaggerEnumTypes([]string{dir}) + if err == nil { + t.Fatal("expected error for annotation without consts") + } + if !strings.Contains(err.Error(), "Lonely") { + t.Fatalf("error %q should mention Lonely", err.Error()) + } +} + +func TestScanSwaggerEnumTypes_constsAndTypeInDifferentFiles(t *testing.T) { + dir := t.TempDir() + // Name ordering: `a_consts.go` < `b_type.go`, so readdir returns consts first. + // Old single-pass scanner would miss the values; two-pass must not. + constsSrc := `package fixture + +const ( + HueA Hue = "a" + HueB Hue = "b" +) +` + typeSrc := `package fixture + +// swagger:enum Hue +type Hue string +` + if err := os.WriteFile(filepath.Join(dir, "a_consts.go"), []byte(constsSrc), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "b_type.go"), []byte(typeSrc), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"a", "b"}) + if single(got, wantKey) != "Hue" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue") + } +} + +func TestScanSwaggerEnumTypes_constsBeforeType(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +const ( + ShadeDark Shade = "dark" + ShadeLight Shade = "light" +) + +// swagger:enum Shade +type Shade string +` + if err := os.WriteFile(filepath.Join(dir, "shade.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + wantKey := EnumKey([]any{"dark", "light"}) + if single(got, wantKey) != "Shade" { + t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade") + } +} + +func TestScanSwaggerEnumTypes_groupedTypeDecl(t *testing.T) { + dir := t.TempDir() + src := `package fixture + +type ( + // swagger:enum Color + Color string + // swagger:enum Shade + Shade string +) + +const ( + ColorRed Color = "red" + ColorBlue Color = "blue" +) + +const ( + ShadeDark Shade = "dark" + ShadeLight Shade = "light" +) +` + if err := os.WriteFile(filepath.Join(dir, "grouped.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ScanSwaggerEnumTypes([]string{dir}) + if err != nil { + t.Fatalf("ScanSwaggerEnumTypes: %v", err) + } + colorKey := EnumKey([]any{"red", "blue"}) + shadeKey := EnumKey([]any{"dark", "light"}) + if single(got, colorKey) != "Color" { + t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color") + } + if single(got, shadeKey) != "Shade" { + t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade") + } +} diff --git a/build/update-locales.sh b/build/update-locales.sh new file mode 100755 index 0000000..5316746 --- /dev/null +++ b/build/update-locales.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# this script runs in alpine image which only has `sh` shell +if [ ! -f ./options/locale/locale_en-US.json ]; then + echo "please run this script in the root directory of the project" + exit 1 +fi + +mv ./options/locale/locale_en-US.json ./options/ + +# Remove translation under 25% of en_us +baselines=$(cat "./options/locale_en-US.json" | wc -l) +baselines=$((baselines / 4)) +for filename in ./options/locale/*.json; do + lines=$(cat "$filename" | wc -l) + if [ "$lines" -lt "$baselines" ]; then + echo "Removing $filename: $lines/$baselines" + rm "$filename" + fi +done + +mv ./options/locale_en-US.json ./options/locale/ diff --git a/cmd/actions.go b/cmd/actions.go new file mode 100644 index 0000000..1a1ad28 --- /dev/null +++ b/cmd/actions.go @@ -0,0 +1,53 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/modules/private" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func newActionsCommand() *cli.Command { + return &cli.Command{ + Name: "actions", + Usage: "Manage Gitea Actions", + Commands: []*cli.Command{ + newActionsGenerateRunnerTokenCommand(), + }, + } +} + +func newActionsGenerateRunnerTokenCommand() *cli.Command { + return &cli.Command{ + Name: "generate-runner-token", + Usage: "Generate a new token for a runner to use to register with the server", + Action: runGenerateActionsRunnerToken, + Aliases: []string{"grt"}, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "scope", + Aliases: []string{"s"}, + Value: "", + Usage: "{owner}[/{repo}] - leave empty for a global runner", + }, + }, + } +} + +func runGenerateActionsRunnerToken(ctx context.Context, c *cli.Command) error { + setting.MustInstalled() + + scope := c.String("scope") + + respText, extra := private.GenerateActionsRunnerToken(ctx, scope) + if extra.HasError() { + return handleCliResponseExtra(extra) + } + cprintln(c, respText.Text) + return nil +} diff --git a/cmd/admin.go b/cmd/admin.go new file mode 100644 index 0000000..6d9a198 --- /dev/null +++ b/cmd/admin.go @@ -0,0 +1,176 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/git" + "gitea.dev/modules/log" + repo_module "gitea.dev/modules/repository" + + "github.com/urfave/cli/v3" +) + +func newAdminCommand() *cli.Command { + return &cli.Command{ + Name: "admin", + Usage: "Perform common administrative operations", + Commands: []*cli.Command{ + newUserCommand(), + newRepoSyncReleasesCommand(), + newRegenerateCommand(), + newAuthCommand(), + newSendMailCommand(), + }, + } +} + +func newRepoSyncReleasesCommand() *cli.Command { + return &cli.Command{ + Name: "repo-sync-releases", + Usage: "Synchronize repository releases with tags", + Action: runRepoSyncReleases, + } +} + +func newRegenerateCommand() *cli.Command { + return &cli.Command{ + Name: "regenerate", + Usage: "Regenerate specific files", + Commands: []*cli.Command{ + newRegenerateHooksCommand(), + newRegenerateKeysCommand(), + }, + } +} + +func newAuthCommand() *cli.Command { + return &cli.Command{ + Name: "auth", + Usage: "Modify external auth providers", + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + return cliAuditContext(ctx), nil + }, + Commands: []*cli.Command{ + microcmdAuthAddOauth(), + microcmdAuthUpdateOauth(), + microcmdAuthAddLdapBindDn(), + microcmdAuthUpdateLdapBindDn(), + microcmdAuthAddLdapSimpleAuth(), + microcmdAuthUpdateLdapSimpleAuth(), + microcmdAuthAddSMTP(), + microcmdAuthUpdateSMTP(), + newAuthListCommand(), + newAuthDeleteCommand(), + }, + } +} + +func newSendMailCommand() *cli.Command { + return &cli.Command{ + Name: "sendmail", + Usage: "Send a message to all users", + Action: runSendMail, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "title", + Usage: "a title of a message", + Required: true, + }, + &cli.StringFlag{ + Name: "content", + Usage: "a content of a message", + Value: "", + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "A flag to bypass a confirmation step", + }, + }, + } +} + +func idFlag() *cli.Int64Flag { + return &cli.Int64Flag{ + Name: "id", + Usage: "ID of authentication source", + } +} + +func runRepoSyncReleases(ctx context.Context, _ *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + if err := git.InitSimple(); err != nil { + return err + } + + log.Trace("Synchronizing repository releases (this may take a while)") + for page := 1; ; page++ { + repos, count, err := repo_model.SearchRepositoryByName(ctx, repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: repo_model.RepositoryListDefaultPageSize, + Page: page, + }, + Private: true, + }) + if err != nil { + return fmt.Errorf("SearchRepositoryByName: %w", err) + } + if len(repos) == 0 { + break + } + log.Trace("Processing next %d repos of %d", len(repos), count) + for _, repo := range repos { + log.Trace("Synchronizing repo %s", repo.FullName()) + gitRepo, err := git.OpenRepository(ctx, repo) + if err != nil { + log.Warn("OpenRepository: %v", err) + continue + } + + oldnum, err := getReleaseCount(ctx, repo.ID) + if err != nil { + log.Warn(" GetReleaseCountByRepoID: %v", err) + } + log.Trace(" currentNumReleases is %d, running SyncReleasesWithTags", oldnum) + + if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { + log.Warn(" SyncReleasesWithTags: %v", err) + gitRepo.Close() + continue + } + + count, err = getReleaseCount(ctx, repo.ID) + if err != nil { + log.Warn(" GetReleaseCountByRepoID: %v", err) + gitRepo.Close() + continue + } + + log.Trace("repo %s releases synchronized to tags: from %d to %d", + repo.FullName(), oldnum, count) + gitRepo.Close() + } + } + + return nil +} + +func getReleaseCount(ctx context.Context, id int64) (int64, error) { + return db.Count[repo_model.Release]( + ctx, + repo_model.FindReleasesOptions{ + RepoID: id, + IncludeTags: true, + }, + ) +} diff --git a/cmd/admin_auth.go b/cmd/admin_auth.go new file mode 100644 index 0000000..6a00e96 --- /dev/null +++ b/cmd/admin_auth.go @@ -0,0 +1,109 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "text/tabwriter" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + auth_service "gitea.dev/services/auth" + + "github.com/urfave/cli/v3" +) + +func newAuthDeleteCommand() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete specific auth source", + Flags: []cli.Flag{idFlag()}, + Action: runDeleteAuth, + } +} + +func newAuthListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List auth sources", + Action: runListAuth, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "min-width", + Usage: "Minimal cell width including any padding for the formatted table", + Value: 0, + }, + &cli.IntFlag{ + Name: "tab-width", + Usage: "width of tab characters in formatted table (equivalent number of spaces)", + Value: 8, + }, + &cli.IntFlag{ + Name: "padding", + Usage: "padding added to a cell before computing its width", + Value: 1, + }, + &cli.StringFlag{ + Name: "pad-char", + Usage: `ASCII char used for padding if padchar == '\\t', the Writer will assume that the width of a '\\t' in the formatted output is tabwidth, and cells are left-aligned independent of align_left (for correct-looking results, tabwidth must correspond to the tab width in the viewer displaying the result)`, + Value: "\t", + }, + &cli.BoolFlag{ + Name: "vertical-bars", + Usage: "Set to true to print vertical bars between columns", + }, + }, + } +} + +func runListAuth(ctx context.Context, c *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{}) + if err != nil { + return err + } + + flags := tabwriter.AlignRight + if c.Bool("vertical-bars") { + flags |= tabwriter.Debug + } + + padChar := byte('\t') + if len(c.String("pad-char")) > 0 { + padChar = c.String("pad-char")[0] + } + + // loop through each source and print + w := tabwriter.NewWriter(os.Stdout, c.Int("min-width"), c.Int("tab-width"), c.Int("padding"), padChar, flags) + fmt.Fprintf(w, "ID\tName\tType\tEnabled\n") + for _, source := range authSources { + fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", source.ID, source.Name, source.Type.String(), source.IsActive) + } + w.Flush() + + return nil +} + +func runDeleteAuth(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + if err := initDB(ctx); err != nil { + return err + } + + source, err := auth_model.GetSourceByID(ctx, c.Int64("id")) + if err != nil { + return err + } + + return auth_service.DeleteSource(ctx, source) +} diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go new file mode 100644 index 0000000..b7d5071 --- /dev/null +++ b/cmd/admin_auth_ldap.go @@ -0,0 +1,473 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "strings" + + "gitea.dev/models/auth" + "gitea.dev/modules/util" + auth_service "gitea.dev/services/auth" + "gitea.dev/services/auth/source/ldap" + + "github.com/urfave/cli/v3" +) + +type ( + authService struct { + initDB func(ctx context.Context) error + createAuthSource func(context.Context, *auth.Source) error + updateAuthSource func(context.Context, *auth.Source) error + getAuthSourceByID func(ctx context.Context, id int64) (*auth.Source, error) + } +) + +func commonLdapCLIFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "Authentication name.", + }, + &cli.BoolFlag{ + Name: "not-active", + Usage: "Deactivate the authentication source.", + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Activate the authentication source.", + }, + &cli.StringFlag{ + Name: "security-protocol", + Usage: "Security protocol name.", + }, + &cli.BoolFlag{ + Name: "skip-tls-verify", + Usage: "Disable TLS verification.", + }, + &cli.StringFlag{ + Name: "host", + Usage: "The address where the LDAP server can be reached.", + }, + &cli.IntFlag{ + Name: "port", + Usage: "The port to use when connecting to the LDAP server.", + }, + &cli.StringFlag{ + Name: "user-search-base", + Usage: "The LDAP base at which user accounts will be searched for.", + }, + &cli.StringFlag{ + Name: "user-filter", + Usage: "An LDAP filter declaring how to find the user record that is attempting to authenticate.", + }, + &cli.StringFlag{ + Name: "admin-filter", + Usage: "An LDAP filter specifying if a user should be given administrator privileges.", + }, + &cli.StringFlag{ + Name: "restricted-filter", + Usage: "An LDAP filter specifying if a user should be given restricted status.", + }, + &cli.BoolFlag{ + Name: "allow-deactivate-all", + Usage: "Allow empty search results to deactivate all users.", + }, + &cli.StringFlag{ + Name: "username-attribute", + Usage: "The attribute of the user’s LDAP record containing the user name.", + }, + &cli.StringFlag{ + Name: "firstname-attribute", + Usage: "The attribute of the user’s LDAP record containing the user’s first name.", + }, + &cli.StringFlag{ + Name: "surname-attribute", + Usage: "The attribute of the user’s LDAP record containing the user’s surname.", + }, + &cli.StringFlag{ + Name: "email-attribute", + Usage: "The attribute of the user’s LDAP record containing the user’s email address.", + }, + &cli.StringFlag{ + Name: "public-ssh-key-attribute", + Usage: "The attribute of the user’s LDAP record containing the user’s public ssh key.", + }, + &cli.BoolFlag{ + Name: "ssh-keys-are-verified", + Usage: "Set to true to automatically flag SSH keys in LDAP as verified.", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Set to true to skip local 2fa for users authenticated by this source", + }, + &cli.StringFlag{ + Name: "avatar-attribute", + Usage: "The attribute of the user’s LDAP record containing the user’s avatar.", + }, + } +} + +func ldapBindDnCLIFlags() []cli.Flag { + return append(commonLdapCLIFlags(), + &cli.StringFlag{ + Name: "bind-dn", + Usage: "The DN to bind to the LDAP server with when searching for the user.", + }, + &cli.StringFlag{ + Name: "bind-password", + Usage: "The password for the Bind DN, if any.", + }, + &cli.BoolFlag{ + Name: "attributes-in-bind", + Usage: "Fetch attributes in bind DN context.", + }, + &cli.BoolFlag{ + Name: "synchronize-users", + Usage: "Enable user synchronization.", + }, + &cli.BoolFlag{ + Name: "disable-synchronize-users", + Usage: "Disable user synchronization.", + }, + &cli.UintFlag{ + Name: "page-size", + Usage: "Search page size.", + }, + &cli.BoolFlag{ + Name: "enable-groups", + Usage: "Enable LDAP groups", + }, + &cli.StringFlag{ + Name: "group-search-base-dn", + Usage: "The LDAP base DN at which group accounts will be searched for", + }, + &cli.StringFlag{ + Name: "group-member-attribute", + Usage: "Group attribute containing list of users", + }, + &cli.StringFlag{ + Name: "group-user-attribute", + Usage: "User attribute listed in group", + }, + &cli.StringFlag{ + Name: "group-filter", + Usage: "Verify group membership in LDAP", + }, + &cli.StringFlag{ + Name: "group-team-map", + Usage: "Map LDAP groups to Organization teams", + }, + &cli.BoolFlag{ + Name: "group-team-map-removal", + Usage: "Remove users from synchronized teams if user does not belong to corresponding LDAP group", + }) +} + +func ldapSimpleAuthCLIFlags() []cli.Flag { + return append(commonLdapCLIFlags(), + &cli.StringFlag{ + Name: "user-dn", + Usage: "The user's DN.", + }) +} + +func microcmdAuthAddLdapBindDn() *cli.Command { + return &cli.Command{ + Name: "add-ldap", + Usage: "Add new LDAP (via Bind DN) authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().addLdapBindDn(ctx, cmd) + }, + Flags: ldapBindDnCLIFlags(), + } +} + +func microcmdAuthUpdateLdapBindDn() *cli.Command { + return &cli.Command{ + Name: "update-ldap", + Usage: "Update existing LDAP (via Bind DN) authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().updateLdapBindDn(ctx, cmd) + }, + Flags: append([]cli.Flag{idFlag()}, ldapBindDnCLIFlags()...), + } +} + +func microcmdAuthAddLdapSimpleAuth() *cli.Command { + return &cli.Command{ + Name: "add-ldap-simple", + Usage: "Add new LDAP (simple auth) authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().addLdapSimpleAuth(ctx, cmd) + }, + Flags: ldapSimpleAuthCLIFlags(), + } +} + +func microcmdAuthUpdateLdapSimpleAuth() *cli.Command { + return &cli.Command{ + Name: "update-ldap-simple", + Usage: "Update existing LDAP (simple auth) authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().updateLdapSimpleAuth(ctx, cmd) + }, + Flags: append([]cli.Flag{idFlag()}, ldapSimpleAuthCLIFlags()...), + } +} + +// newAuthService creates a service with default functions. +func newAuthService() *authService { + return &authService{ + initDB: initDB, + createAuthSource: auth_service.CreateSource, + updateAuthSource: auth_service.UpdateSource, + getAuthSourceByID: auth.GetSourceByID, + } +} + +// parseAuthSourceLdap assigns values on authSource according to command line flags. +func parseAuthSourceLdap(c *cli.Command, authSource *auth.Source) { + if c.IsSet("name") { + authSource.Name = c.String("name") + } + if c.IsSet("not-active") { + authSource.IsActive = !c.Bool("not-active") + } + if c.IsSet("active") { + authSource.IsActive = c.Bool("active") + } + if c.IsSet("synchronize-users") { + authSource.IsSyncEnabled = c.Bool("synchronize-users") + } + if c.IsSet("disable-synchronize-users") { + authSource.IsSyncEnabled = !c.Bool("disable-synchronize-users") + } + authSource.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "") +} + +// parseLdapConfig assigns values on config according to command line flags. +func parseLdapConfig(c *cli.Command, config *ldap.Source) error { + if c.IsSet("name") { + config.Name = c.String("name") + } + if c.IsSet("host") { + config.Host = c.String("host") + } + if c.IsSet("port") { + config.Port = c.Int("port") + } + if c.IsSet("security-protocol") { + p, ok := findLdapSecurityProtocolByName(c.String("security-protocol")) + if !ok { + return fmt.Errorf("unknown security protocol name: %s", c.String("security-protocol")) + } + config.SecurityProtocol = p + } + if c.IsSet("skip-tls-verify") { + config.SkipVerify = c.Bool("skip-tls-verify") + } + if c.IsSet("bind-dn") { + config.BindDN = c.String("bind-dn") + } + if c.IsSet("user-dn") { + config.UserDN = c.String("user-dn") + } + if c.IsSet("bind-password") { + config.BindPassword = c.String("bind-password") + } + if c.IsSet("user-search-base") { + config.UserBase = c.String("user-search-base") + } + if c.IsSet("username-attribute") { + config.AttributeUsername = c.String("username-attribute") + } + if c.IsSet("firstname-attribute") { + config.AttributeName = c.String("firstname-attribute") + } + if c.IsSet("surname-attribute") { + config.AttributeSurname = c.String("surname-attribute") + } + if c.IsSet("email-attribute") { + config.AttributeMail = c.String("email-attribute") + } + if c.IsSet("attributes-in-bind") { + config.AttributesInBind = c.Bool("attributes-in-bind") + } + if c.IsSet("public-ssh-key-attribute") { + config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") + } + if c.IsSet("ssh-keys-are-verified") { + config.SSHKeysAreVerified = c.Bool("ssh-keys-are-verified") + } + if c.IsSet("avatar-attribute") { + config.AttributeAvatar = c.String("avatar-attribute") + } + if c.IsSet("page-size") { + config.SearchPageSize = uint32(c.Uint("page-size")) + } + if c.IsSet("user-filter") { + config.Filter = c.String("user-filter") + } + if c.IsSet("admin-filter") { + config.AdminFilter = c.String("admin-filter") + } + if c.IsSet("restricted-filter") { + config.RestrictedFilter = c.String("restricted-filter") + } + if c.IsSet("allow-deactivate-all") { + config.AllowDeactivateAll = c.Bool("allow-deactivate-all") + } + if c.IsSet("enable-groups") { + config.GroupsEnabled = c.Bool("enable-groups") + } + if c.IsSet("group-search-base-dn") { + config.GroupDN = c.String("group-search-base-dn") + } + if c.IsSet("group-member-attribute") { + config.GroupMemberUID = c.String("group-member-attribute") + } + if c.IsSet("group-user-attribute") { + config.UserUID = c.String("group-user-attribute") + } + if c.IsSet("group-filter") { + config.GroupFilter = c.String("group-filter") + } + if c.IsSet("group-team-map") { + config.GroupTeamMap = c.String("group-team-map") + } + if c.IsSet("group-team-map-removal") { + config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") + } + return nil +} + +// findLdapSecurityProtocolByName finds security protocol by its name ignoring case. +// It returns the value of the security protocol and if it was found. +func findLdapSecurityProtocolByName(name string) (ldap.SecurityProtocol, bool) { + for i, n := range ldap.SecurityProtocolNames { + if strings.EqualFold(name, n) { + return i, true + } + } + return 0, false +} + +// getAuthSourceOfType gets the login source by id. +// It returns an error if the id does not match any source or if the source is not of the expected type. +func (a *authService) getAuthSourceOfType(ctx context.Context, id int64, authType auth.Type) (*auth.Source, error) { + authSource, err := a.getAuthSourceByID(ctx, id) + if err != nil { + return nil, err + } + + if authSource.Type != authType { + return nil, fmt.Errorf("invalid authentication type. expected: %s, actual: %s", authType.String(), authSource.Type.String()) + } + + return authSource, nil +} + +// getAuthSource gets the login source by its id defined in the command line flags. +// It returns an error if the id is not set, does not match any source or if the source is not of expected type. +func (a *authService) getAuthSource(ctx context.Context, c *cli.Command, authType auth.Type) (*auth.Source, error) { + if err := argsSet(c, "id"); err != nil { + return nil, err + } + return a.getAuthSourceOfType(ctx, c.Int64("id"), authType) +} + +// addLdapBindDn adds a new LDAP via Bind DN authentication source. +func (a *authService) addLdapBindDn(ctx context.Context, c *cli.Command) error { + if err := argsSet(c, "name", "security-protocol", "host", "port", "user-search-base", "user-filter", "email-attribute"); err != nil { + return err + } + if err := a.initDB(ctx); err != nil { + return err + } + + ldapConfig := &ldap.Source{ + Enabled: true, // always true + } + authSource := &auth.Source{ + Type: auth.LDAP, + IsActive: true, // active by default + Cfg: ldapConfig, + } + + parseAuthSourceLdap(c, authSource) + if err := parseLdapConfig(c, ldapConfig); err != nil { + return err + } + + return a.createAuthSource(ctx, authSource) +} + +// updateLdapBindDn updates a new LDAP via Bind DN authentication source. +func (a *authService) updateLdapBindDn(ctx context.Context, c *cli.Command) error { + if err := a.initDB(ctx); err != nil { + return err + } + + authSource, err := a.getAuthSource(ctx, c, auth.LDAP) + if err != nil { + return err + } + ldapConfig := auth.MustSourceCfg[*ldap.Source](authSource) + + parseAuthSourceLdap(c, authSource) + if err := parseLdapConfig(c, ldapConfig); err != nil { + return err + } + + return a.updateAuthSource(ctx, authSource) +} + +// addLdapSimpleAuth adds a new LDAP (simple auth) authentication source. +func (a *authService) addLdapSimpleAuth(ctx context.Context, c *cli.Command) error { + if err := argsSet(c, "name", "security-protocol", "host", "port", "user-dn", "user-filter", "email-attribute"); err != nil { + return err + } + + if err := a.initDB(ctx); err != nil { + return err + } + + ldapConfig := &ldap.Source{ + Enabled: true, // always true + } + authSource := &auth.Source{ + Type: auth.DLDAP, + IsActive: true, // active by default + Cfg: ldapConfig, + } + + parseAuthSourceLdap(c, authSource) + if err := parseLdapConfig(c, ldapConfig); err != nil { + return err + } + + return a.createAuthSource(ctx, authSource) +} + +// updateLdapSimpleAuth updates a new LDAP (simple auth) authentication source. +func (a *authService) updateLdapSimpleAuth(ctx context.Context, c *cli.Command) error { + if err := a.initDB(ctx); err != nil { + return err + } + + authSource, err := a.getAuthSource(ctx, c, auth.DLDAP) + if err != nil { + return err + } + ldapConfig := auth.MustSourceCfg[*ldap.Source](authSource) + + parseAuthSourceLdap(c, authSource) + if err := parseLdapConfig(c, ldapConfig); err != nil { + return err + } + + return a.updateAuthSource(ctx, authSource) +} diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go new file mode 100644 index 0000000..f9b5338 --- /dev/null +++ b/cmd/admin_auth_ldap_test.go @@ -0,0 +1,1348 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "gitea.dev/models/auth" + "gitea.dev/modules/test" + "gitea.dev/services/auth/source/ldap" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestAddLdapBindDn(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + source *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source full", + "--not-active", + "--security-protocol", "ldaps", + "--skip-tls-verify", + "--host", "ldap-bind-server full", + "--port", "9876", + "--user-search-base", "ou=Users,dc=full-domain-bind,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", + "--restricted-filter", "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", + "--username-attribute", "uid-bind full", + "--firstname-attribute", "givenName-bind full", + "--surname-attribute", "sn-bind full", + "--email-attribute", "mail-bind full", + "--public-ssh-key-attribute", "publickey-bind full", + "--avatar-attribute", "avatar-bind full", + "--bind-dn", "cn=readonly,dc=full-domain-bind,dc=org", + "--bind-password", "secret-bind-full", + "--attributes-in-bind", + "--synchronize-users", + "--page-size", "99", + "--enable-groups", + "--group-search-base-dn", "ou=group,dc=full-domain-bind,dc=org", + "--group-member-attribute", "memberUid", + "--group-user-attribute", "uid", + "--group-filter", "(|(cn=gitea_users)(cn=admins))", + "--group-team-map", `{"cn=my-group,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}`, + "--group-team-map-removal", + }, + source: &auth.Source{ + Type: auth.LDAP, + Name: "ldap (via Bind DN) source full", + IsActive: false, + IsSyncEnabled: true, + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) source full", + Host: "ldap-bind-server full", + Port: 9876, + SecurityProtocol: ldap.SecurityProtocol(1), + SkipVerify: true, + BindDN: "cn=readonly,dc=full-domain-bind,dc=org", + BindPassword: "secret-bind-full", + UserBase: "ou=Users,dc=full-domain-bind,dc=org", + AttributeUsername: "uid-bind full", + AttributeName: "givenName-bind full", + AttributeSurname: "sn-bind full", + AttributeMail: "mail-bind full", + AttributesInBind: true, + AttributeSSHPublicKey: "publickey-bind full", + AttributeAvatar: "avatar-bind full", + SearchPageSize: 99, + Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", + RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", + Enabled: true, + GroupsEnabled: true, + GroupDN: "ou=group,dc=full-domain-bind,dc=org", + GroupMemberUID: "memberUid", + UserUID: "uid", + GroupFilter: "(|(cn=gitea_users)(cn=admins))", + GroupTeamMap: `{"cn=my-group,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}`, + GroupTeamMapRemoval: true, + }, + }, + }, + // case 1 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source min", + "--security-protocol", "unencrypted", + "--host", "ldap-bind-server min", + "--port", "1234", + "--user-search-base", "ou=Users,dc=min-domain-bind,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=min-domain-bind,dc=org)", + "--email-attribute", "mail-bind min", + }, + source: &auth.Source{ + Type: auth.LDAP, + Name: "ldap (via Bind DN) source min", + IsActive: true, + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) source min", + Host: "ldap-bind-server min", + Port: 1234, + SecurityProtocol: ldap.SecurityProtocol(0), + UserBase: "ou=Users,dc=min-domain-bind,dc=org", + AttributeMail: "mail-bind min", + Filter: "(memberOf=cn=user-group,ou=example,dc=min-domain-bind,dc=org)", + Enabled: true, + }, + }, + }, + // case 2 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--security-protocol", "zzzzz", + "--host", "ldap-server", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + "--email-attribute", "mail", + }, + errMsg: "unknown security protocol name: zzzzz", + }, + // case 3 + { + args: []string{ + "ldap-test", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + "--email-attribute", "mail", + }, + errMsg: "name is not set", + }, + // case 4 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--host", "ldap-server", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + "--email-attribute", "mail", + }, + errMsg: "security-protocol is not set", + }, + // case 5 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--security-protocol", "unencrypted", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + "--email-attribute", "mail", + }, + errMsg: "host is not set", + }, + // case 6 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + "--email-attribute", "mail", + }, + errMsg: "port is not set", + }, + // case 7 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--email-attribute", "mail", + }, + errMsg: "user-filter is not set", + }, + // case 8 + { + args: []string{ + "ldap-test", + "--name", "ldap (via Bind DN) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "1234", + "--user-search-base", "ou=Users,dc=domain,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + }, + errMsg: "email-attribute is not set", + }, + } + + for n, c := range cases { + // Mock functions. + var createdAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + createdAuthSource = authSource + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "updateAuthSource called", "case %d: should not call updateAuthSource", n) + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + assert.FailNow(t, "getAuthSourceByID called", "case %d: should not call getAuthSourceByID", n) + return nil, nil //nolint:nilnil // mock function covering improper behavior + }, + } + + // Create a copy of command to test + app := cli.Command{ + Flags: microcmdAuthAddLdapBindDn().Flags, + Action: service.addLdapBindDn, + } + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + assert.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.source, createdAuthSource, "case %d: wrong authSource", n) + } + } +} + +func TestAddLdapSimpleAuth(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + authSource *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source full", + "--not-active", + "--security-protocol", "starttls", + "--skip-tls-verify", + "--host", "ldap-simple-server full", + "--port", "987", + "--user-search-base", "ou=Users,dc=full-domain-simple,dc=org", + "--user-filter", "(&(objectClass=posixAccount)(full-simple-cn=%s))", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", + "--restricted-filter", "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", + "--username-attribute", "uid-simple full", + "--firstname-attribute", "givenName-simple full", + "--surname-attribute", "sn-simple full", + "--email-attribute", "mail-simple full", + "--public-ssh-key-attribute", "publickey-simple full", + "--avatar-attribute", "avatar-simple full", + "--user-dn", "cn=%s,ou=Users,dc=full-domain-simple,dc=org", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Name: "ldap (simple auth) source full", + IsActive: false, + Cfg: &ldap.Source{ + Name: "ldap (simple auth) source full", + Host: "ldap-simple-server full", + Port: 987, + SecurityProtocol: ldap.SecurityProtocol(2), + SkipVerify: true, + UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", + UserBase: "ou=Users,dc=full-domain-simple,dc=org", + AttributeUsername: "uid-simple full", + AttributeName: "givenName-simple full", + AttributeSurname: "sn-simple full", + AttributeMail: "mail-simple full", + AttributeSSHPublicKey: "publickey-simple full", + AttributeAvatar: "avatar-simple full", + Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", + RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", + Enabled: true, + }, + }, + }, + // case 1 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source min", + "--security-protocol", "unencrypted", + "--host", "ldap-simple-server min", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(min-simple-cn=%s))", + "--email-attribute", "mail-simple min", + "--user-dn", "cn=%s,ou=Users,dc=min-domain-simple,dc=org", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Name: "ldap (simple auth) source min", + IsActive: true, + Cfg: &ldap.Source{ + Name: "ldap (simple auth) source min", + Host: "ldap-simple-server min", + Port: 123, + SecurityProtocol: ldap.SecurityProtocol(0), + UserDN: "cn=%s,ou=Users,dc=min-domain-simple,dc=org", + AttributeMail: "mail-simple min", + Filter: "(&(objectClass=posixAccount)(min-simple-cn=%s))", + Enabled: true, + }, + }, + }, + // case 2 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "zzzzz", + "--host", "ldap-server", + "--port", "1234", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "unknown security protocol name: zzzzz", + }, + // case 3 + { + args: []string{ + "ldap-test", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "name is not set", + }, + // case 4 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--host", "ldap-server", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "security-protocol is not set", + }, + // case 5 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "unencrypted", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "host is not set", + }, + // case 6 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "port is not set", + }, + // case 7 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "123", + "--email-attribute", "mail", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "user-filter is not set", + }, + // case 8 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + errMsg: "email-attribute is not set", + }, + // case 9 + { + args: []string{ + "ldap-test", + "--name", "ldap (simple auth) source", + "--security-protocol", "unencrypted", + "--host", "ldap-server", + "--port", "123", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + "--email-attribute", "mail", + }, + errMsg: "user-dn is not set", + }, + } + + for n, c := range cases { + // Mock functions. + var createdAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + createdAuthSource = authSource + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "updateAuthSource called", "case %d: should not call updateAuthSource", n) + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + assert.FailNow(t, "getAuthSourceById called", "case %d: should not call getAuthSourceByID", n) + return nil, nil //nolint:nilnil // mock function covering improper behavior + }, + } + + // Create a copy of command to test + app := &cli.Command{ + Flags: microcmdAuthAddLdapSimpleAuth().Flags, + Action: service.addLdapSimpleAuth, + } + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + assert.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.authSource, createdAuthSource, "case %d: wrong authSource", n) + } + } +} + +func TestUpdateLdapBindDn(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + id int64 + existingAuthSource *auth.Source + authSource *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "ldap-test", + "--id", "23", + "--name", "ldap (via Bind DN) source full", + "--not-active", + "--security-protocol", "LDAPS", + "--skip-tls-verify", + "--host", "ldap-bind-server full", + "--port", "9876", + "--user-search-base", "ou=Users,dc=full-domain-bind,dc=org", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", + "--restricted-filter", "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", + "--username-attribute", "uid-bind full", + "--firstname-attribute", "givenName-bind full", + "--surname-attribute", "sn-bind full", + "--email-attribute", "mail-bind full", + "--public-ssh-key-attribute", "publickey-bind full", + "--avatar-attribute", "avatar-bind full", + "--bind-dn", "cn=readonly,dc=full-domain-bind,dc=org", + "--bind-password", "secret-bind-full", + "--synchronize-users", + "--page-size", "99", + "--enable-groups", + "--group-search-base-dn", "ou=group,dc=full-domain-bind,dc=org", + "--group-member-attribute", "memberUid", + "--group-user-attribute", "uid", + "--group-filter", "(|(cn=gitea_users)(cn=admins))", + "--group-team-map", `{"cn=my-group,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}`, + "--group-team-map-removal", + }, + id: 23, + existingAuthSource: &auth.Source{ + Type: auth.LDAP, + IsActive: true, + Cfg: &ldap.Source{ + Enabled: true, + }, + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Name: "ldap (via Bind DN) source full", + IsActive: false, + IsSyncEnabled: true, + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) source full", + Host: "ldap-bind-server full", + Port: 9876, + SecurityProtocol: ldap.SecurityProtocol(1), + SkipVerify: true, + BindDN: "cn=readonly,dc=full-domain-bind,dc=org", + BindPassword: "secret-bind-full", + UserBase: "ou=Users,dc=full-domain-bind,dc=org", + AttributeUsername: "uid-bind full", + AttributeName: "givenName-bind full", + AttributeSurname: "sn-bind full", + AttributeMail: "mail-bind full", + AttributesInBind: false, + AttributeSSHPublicKey: "publickey-bind full", + AttributeAvatar: "avatar-bind full", + SearchPageSize: 99, + Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", + RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", + Enabled: true, + GroupsEnabled: true, + GroupDN: "ou=group,dc=full-domain-bind,dc=org", + GroupMemberUID: "memberUid", + UserUID: "uid", + GroupFilter: "(|(cn=gitea_users)(cn=admins))", + GroupTeamMap: `{"cn=my-group,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}`, + GroupTeamMapRemoval: true, + }, + }, + }, + // case 1 + { + args: []string{ + "ldap-test", + "--id", "1", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{}, + }, + }, + // case 2 + { + args: []string{ + "ldap-test", + "--id", "1", + "--name", "ldap (via Bind DN) source", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Name: "ldap (via Bind DN) source", + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) source", + }, + }, + }, + // case 3 + { + args: []string{ + "ldap-test", + "--id", "1", + "--not-active", + }, + existingAuthSource: &auth.Source{ + Type: auth.LDAP, + IsActive: true, + Cfg: &ldap.Source{}, + }, + authSource: &auth.Source{ + Type: auth.LDAP, + IsActive: false, + Cfg: &ldap.Source{}, + }, + }, + // case 4 + { + args: []string{ + "ldap-test", + "--id", "1", + "--security-protocol", "LDAPS", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + SecurityProtocol: ldap.SecurityProtocol(1), + }, + }, + }, + // case 5 + { + args: []string{ + "ldap-test", + "--id", "1", + "--skip-tls-verify", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + SkipVerify: true, + }, + }, + }, + // case 6 + { + args: []string{ + "ldap-test", + "--id", "1", + "--host", "ldap-server", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + Host: "ldap-server", + }, + }, + }, + // case 7 + { + args: []string{ + "ldap-test", + "--id", "1", + "--port", "389", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + Port: 389, + }, + }, + }, + // case 8 + { + args: []string{ + "ldap-test", + "--id", "1", + "--user-search-base", "ou=Users,dc=domain,dc=org", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + UserBase: "ou=Users,dc=domain,dc=org", + }, + }, + }, + // case 9 + { + args: []string{ + "ldap-test", + "--id", "1", + "--user-filter", "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + Filter: "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", + }, + }, + }, + // case 10 + { + args: []string{ + "ldap-test", + "--id", "1", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", + }, + }, + }, + // case 11 + { + args: []string{ + "ldap-test", + "--id", "1", + "--username-attribute", "uid", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributeUsername: "uid", + }, + }, + }, + // case 12 + { + args: []string{ + "ldap-test", + "--id", "1", + "--firstname-attribute", "givenName", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributeName: "givenName", + }, + }, + }, + // case 13 + { + args: []string{ + "ldap-test", + "--id", "1", + "--surname-attribute", "sn", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributeSurname: "sn", + }, + }, + }, + // case 14 + { + args: []string{ + "ldap-test", + "--id", "1", + "--email-attribute", "mail", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributeMail: "mail", + }, + }, + }, + // case 15 + { + args: []string{ + "ldap-test", + "--id", "1", + "--attributes-in-bind", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributesInBind: true, + }, + }, + }, + // case 16 + { + args: []string{ + "ldap-test", + "--id", "1", + "--public-ssh-key-attribute", "publickey", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + AttributeSSHPublicKey: "publickey", + }, + }, + }, + // case 17 + { + args: []string{ + "ldap-test", + "--id", "1", + "--bind-dn", "cn=readonly,dc=domain,dc=org", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + BindDN: "cn=readonly,dc=domain,dc=org", + }, + }, + }, + // case 18 + { + args: []string{ + "ldap-test", + "--id", "1", + "--bind-password", "secret", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + BindPassword: "secret", + }, + }, + }, + // case 19 + { + args: []string{ + "ldap-test", + "--id", "1", + "--synchronize-users", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + IsSyncEnabled: true, + Cfg: &ldap.Source{}, + }, + }, + // case 20 + { + args: []string{ + "ldap-test", + "--id", "1", + "--page-size", "12", + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{ + SearchPageSize: 12, + }, + }, + }, + // case 21 + { + args: []string{ + "ldap-test", + "--id", "1", + "--security-protocol", "xxxxx", + }, + errMsg: "unknown security protocol name: xxxxx", + }, + // case 22 + { + args: []string{ + "ldap-test", + }, + errMsg: "id is not set", + }, + // case 23 + { + args: []string{ + "ldap-test", + "--id", "1", + }, + existingAuthSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &ldap.Source{}, + }, + errMsg: "invalid authentication type. expected: LDAP (via BindDN), actual: OAuth2", + }, + // case 24 + { + args: []string{ + "ldap-test", + "--id", "24", + "--name", "ldap (via Bind DN) flip 'active' and 'user sync' attributes", + "--active", + "--disable-synchronize-users", + }, + id: 24, + existingAuthSource: &auth.Source{ + Type: auth.LDAP, + IsActive: false, + IsSyncEnabled: true, + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) flip 'active' and 'user sync' attributes", + Enabled: true, + }, + }, + authSource: &auth.Source{ + Type: auth.LDAP, + Name: "ldap (via Bind DN) flip 'active' and 'user sync' attributes", + IsActive: true, + IsSyncEnabled: false, + Cfg: &ldap.Source{ + Name: "ldap (via Bind DN) flip 'active' and 'user sync' attributes", + Enabled: true, + }, + }, + }, + } + + for n, c := range cases { + // Mock functions. + var updatedAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "createAuthSource called", "case %d: should not call createAuthSource", n) + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + updatedAuthSource = authSource + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + if c.id != 0 { + assert.Equal(t, c.id, id, "case %d: wrong id", n) + } + if c.existingAuthSource != nil { + return c.existingAuthSource, nil + } + return &auth.Source{ + Type: auth.LDAP, + Cfg: &ldap.Source{}, + }, nil + }, + } + + // Create a copy of command to test + app := cli.Command{ + Flags: microcmdAuthUpdateLdapBindDn().Flags, + Action: service.updateLdapBindDn, + } + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + assert.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.authSource, updatedAuthSource, "case %d: wrong authSource", n) + } + } +} + +func TestUpdateLdapSimpleAuth(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + id int64 + existingAuthSource *auth.Source + authSource *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "ldap-test", + "--id", "7", + "--name", "ldap (simple auth) source full", + "--not-active", + "--security-protocol", "starttls", + "--skip-tls-verify", + "--host", "ldap-simple-server full", + "--port", "987", + "--user-search-base", "ou=Users,dc=full-domain-simple,dc=org", + "--user-filter", "(&(objectClass=posixAccount)(full-simple-cn=%s))", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", + "--restricted-filter", "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", + "--username-attribute", "uid-simple full", + "--firstname-attribute", "givenName-simple full", + "--surname-attribute", "sn-simple full", + "--email-attribute", "mail-simple full", + "--public-ssh-key-attribute", "publickey-simple full", + "--avatar-attribute", "avatar-simple full", + "--user-dn", "cn=%s,ou=Users,dc=full-domain-simple,dc=org", + }, + id: 7, + authSource: &auth.Source{ + Type: auth.DLDAP, + Name: "ldap (simple auth) source full", + IsActive: false, + Cfg: &ldap.Source{ + Name: "ldap (simple auth) source full", + Host: "ldap-simple-server full", + Port: 987, + SecurityProtocol: ldap.SecurityProtocol(2), + SkipVerify: true, + UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", + UserBase: "ou=Users,dc=full-domain-simple,dc=org", + AttributeUsername: "uid-simple full", + AttributeName: "givenName-simple full", + AttributeSurname: "sn-simple full", + AttributeMail: "mail-simple full", + AttributeSSHPublicKey: "publickey-simple full", + AttributeAvatar: "avatar-simple full", + Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", + RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", + }, + }, + }, + // case 1 + { + args: []string{ + "ldap-test", + "--id", "1", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{}, + }, + }, + // case 2 + { + args: []string{ + "ldap-test", + "--id", "1", + "--name", "ldap (simple auth) source", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Name: "ldap (simple auth) source", + Cfg: &ldap.Source{ + Name: "ldap (simple auth) source", + }, + }, + }, + // case 3 + { + args: []string{ + "ldap-test", + "--id", "1", + "--not-active", + }, + existingAuthSource: &auth.Source{ + Type: auth.DLDAP, + IsActive: true, + Cfg: &ldap.Source{}, + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + IsActive: false, + Cfg: &ldap.Source{}, + }, + }, + // case 4 + { + args: []string{ + "ldap-test", + "--id", "1", + "--security-protocol", "starttls", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + SecurityProtocol: ldap.SecurityProtocol(2), + }, + }, + }, + // case 5 + { + args: []string{ + "ldap-test", + "--id", "1", + "--skip-tls-verify", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + SkipVerify: true, + }, + }, + }, + // case 6 + { + args: []string{ + "ldap-test", + "--id", "1", + "--host", "ldap-server", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + Host: "ldap-server", + }, + }, + }, + // case 7 + { + args: []string{ + "ldap-test", + "--id", "1", + "--port", "987", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + Port: 987, + }, + }, + }, + // case 8 + { + args: []string{ + "ldap-test", + "--id", "1", + "--user-search-base", "ou=Users,dc=domain,dc=org", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + UserBase: "ou=Users,dc=domain,dc=org", + }, + }, + }, + // case 9 + { + args: []string{ + "ldap-test", + "--id", "1", + "--user-filter", "(&(objectClass=posixAccount)(cn=%s))", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + Filter: "(&(objectClass=posixAccount)(cn=%s))", + }, + }, + }, + // case 10 + { + args: []string{ + "ldap-test", + "--id", "1", + "--admin-filter", "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", + }, + }, + }, + // case 11 + { + args: []string{ + "ldap-test", + "--id", "1", + "--username-attribute", "uid", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AttributeUsername: "uid", + }, + }, + }, + // case 12 + { + args: []string{ + "ldap-test", + "--id", "1", + "--firstname-attribute", "givenName", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AttributeName: "givenName", + }, + }, + }, + // case 13 + { + args: []string{ + "ldap-test", + "--id", "1", + "--surname-attribute", "sn", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AttributeSurname: "sn", + }, + }, + }, + // case 14 + { + args: []string{ + "ldap-test", + "--id", "1", + "--email-attribute", "mail", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AttributeMail: "mail", + }, + }, + }, + // case 15 + { + args: []string{ + "ldap-test", + "--id", "1", + "--public-ssh-key-attribute", "publickey", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + AttributeSSHPublicKey: "publickey", + }, + }, + }, + // case 16 + { + args: []string{ + "ldap-test", + "--id", "1", + "--user-dn", "cn=%s,ou=Users,dc=domain,dc=org", + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{ + UserDN: "cn=%s,ou=Users,dc=domain,dc=org", + }, + }, + }, + // case 17 + { + args: []string{ + "ldap-test", + "--id", "1", + "--security-protocol", "xxxxx", + }, + errMsg: "unknown security protocol name: xxxxx", + }, + // case 18 + { + args: []string{ + "ldap-test", + }, + errMsg: "id is not set", + }, + // case 19 + { + args: []string{ + "ldap-test", + "--id", "1", + }, + existingAuthSource: &auth.Source{ + Type: auth.PAM, + Cfg: &ldap.Source{}, + }, + errMsg: "invalid authentication type. expected: LDAP (simple auth), actual: PAM", + }, + // case 20 + { + args: []string{ + "ldap-test", + "--id", "20", + "--name", "ldap (simple auth) flip 'active' attribute", + "--active", + }, + id: 20, + existingAuthSource: &auth.Source{ + Type: auth.DLDAP, + IsActive: false, + Cfg: &ldap.Source{ + Name: "ldap (simple auth) flip 'active' attribute", + Enabled: true, + }, + }, + authSource: &auth.Source{ + Type: auth.DLDAP, + Name: "ldap (simple auth) flip 'active' attribute", + IsActive: true, + Cfg: &ldap.Source{ + Name: "ldap (simple auth) flip 'active' attribute", + Enabled: true, + }, + }, + }, + } + + for n, c := range cases { + // Mock functions. + var updatedAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "createAuthSource called", "case %d: should not call createAuthSource", n) + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + updatedAuthSource = authSource + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + if c.id != 0 { + assert.Equal(t, c.id, id, "case %d: wrong id", n) + } + if c.existingAuthSource != nil { + return c.existingAuthSource, nil + } + return &auth.Source{ + Type: auth.DLDAP, + Cfg: &ldap.Source{}, + }, nil + }, + } + + // Create a copy of command to test + app := cli.Command{ + Flags: microcmdAuthUpdateLdapSimpleAuth().Flags, + Action: service.updateLdapSimpleAuth, + } + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + assert.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.authSource, updatedAuthSource, "case %d: wrong authSource", n) + } + } +} diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go new file mode 100644 index 0000000..f44327d --- /dev/null +++ b/cmd/admin_auth_oauth.go @@ -0,0 +1,321 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "net/url" + + auth_model "gitea.dev/models/auth" + "gitea.dev/modules/util" + "gitea.dev/services/auth/source/oauth2" + + "github.com/urfave/cli/v3" +) + +func oauthCLIFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "Application Name", + }, + &cli.StringFlag{ + Name: "provider", + Value: "", + Usage: "OAuth2 Provider", + }, + &cli.StringFlag{ + Name: "key", + Value: "", + Usage: "Client ID (Key)", + }, + &cli.StringFlag{ + Name: "secret", + Value: "", + Usage: "Client Secret", + }, + &cli.StringFlag{ + Name: "auto-discover-url", + Value: "", + Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)", + }, + &cli.StringFlag{ + Name: "use-custom-urls", + Value: "false", + Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints", + }, + &cli.StringFlag{ + Name: "custom-tenant-id", + Value: "", + Usage: "Use custom Tenant ID for OAuth endpoints", + }, + &cli.StringFlag{ + Name: "custom-auth-url", + Value: "", + Usage: "Use a custom Authorization URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-token-url", + Value: "", + Usage: "Use a custom Token URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-profile-url", + Value: "", + Usage: "Use a custom Profile URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-email-url", + Value: "", + Usage: "Use a custom Email URL (option for GitHub)", + }, + &cli.StringFlag{ + Name: "icon-url", + Value: "", + Usage: "Custom icon URL for OAuth2 login source", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Set to true to skip local 2fa for users authenticated by this source", + }, + &cli.StringSliceFlag{ + Name: "scopes", + Value: nil, + Usage: "Scopes to request when to authenticate against this OAuth2 source", + }, + &cli.StringFlag{ + Name: "ssh-public-key-claim-name", + Usage: "Claim name that provides SSH public keys", + }, + &cli.StringFlag{ + Name: "full-name-claim-name", + Usage: "Claim name that provides user's full name", + }, + &cli.StringFlag{ + Name: "required-claim-name", + Value: "", + Usage: "Claim name that has to be set to allow users to login with this source", + }, + &cli.StringFlag{ + Name: "required-claim-value", + Value: "", + Usage: "Claim value that has to be set to allow users to login with this source", + }, + &cli.StringFlag{ + Name: "group-claim-name", + Value: "", + Usage: "Claim name providing group names for this source", + }, + &cli.StringFlag{ + Name: "admin-group", + Value: "", + Usage: "Group Claim value for administrator users", + }, + &cli.StringFlag{ + Name: "restricted-group", + Value: "", + Usage: "Group Claim value for restricted users", + }, + &cli.StringFlag{ + Name: "group-team-map", + Value: "", + Usage: "JSON mapping between groups and org teams", + }, + &cli.BoolFlag{ + Name: "group-team-map-removal", + Usage: "Activate automatic team membership removal depending on groups", + }, + } +} + +func microcmdAuthAddOauth() *cli.Command { + return &cli.Command{ + Name: "add-oauth", + Usage: "Add new Oauth authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().runAddOauth(ctx, cmd) + }, + Flags: oauthCLIFlags(), + } +} + +func microcmdAuthUpdateOauth() *cli.Command { + return &cli.Command{ + Name: "update-oauth", + Usage: "Update existing Oauth authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().runUpdateOauth(ctx, cmd) + }, + Flags: append(oauthCLIFlags()[:1], append([]cli.Flag{&cli.Int64Flag{ + Name: "id", + Usage: "ID of authentication source", + }}, oauthCLIFlags()[1:]...)...), + } +} + +func parseOAuth2Config(c *cli.Command) *oauth2.Source { + var customURLMapping *oauth2.CustomURLMapping + if c.IsSet("use-custom-urls") { + customURLMapping = &oauth2.CustomURLMapping{ + TokenURL: c.String("custom-token-url"), + AuthURL: c.String("custom-auth-url"), + ProfileURL: c.String("custom-profile-url"), + EmailURL: c.String("custom-email-url"), + Tenant: c.String("custom-tenant-id"), + } + } else { + customURLMapping = nil + } + return &oauth2.Source{ + Provider: c.String("provider"), + ClientID: c.String("key"), + ClientSecret: c.String("secret"), + OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"), + CustomURLMapping: customURLMapping, + IconURL: c.String("icon-url"), + Scopes: c.StringSlice("scopes"), + RequiredClaimName: c.String("required-claim-name"), + RequiredClaimValue: c.String("required-claim-value"), + GroupClaimName: c.String("group-claim-name"), + AdminGroup: c.String("admin-group"), + RestrictedGroup: c.String("restricted-group"), + GroupTeamMap: c.String("group-team-map"), + GroupTeamMapRemoval: c.Bool("group-team-map-removal"), + SSHPublicKeyClaimName: c.String("ssh-public-key-claim-name"), + FullNameClaimName: c.String("full-name-claim-name"), + } +} + +func (a *authService) runAddOauth(ctx context.Context, c *cli.Command) error { + if err := a.initDB(ctx); err != nil { + return err + } + + config := parseOAuth2Config(c) + if config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL) + } + } + + return a.createAuthSource(ctx, &auth_model.Source{ + Type: auth_model.OAuth2, + Name: c.String("name"), + IsActive: true, + Cfg: config, + TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""), + }) +} + +func (a *authService) runUpdateOauth(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + if err := a.initDB(ctx); err != nil { + return err + } + + source, err := a.getAuthSourceOfType(ctx, c.Int64("id"), auth_model.OAuth2) + if err != nil { + return err + } + oAuth2Config := auth_model.MustSourceCfg[*oauth2.Source](source) + + if c.IsSet("name") { + source.Name = c.String("name") + } + + if c.IsSet("provider") { + oAuth2Config.Provider = c.String("provider") + } + + if c.IsSet("key") { + oAuth2Config.ClientID = c.String("key") + } + + if c.IsSet("secret") { + oAuth2Config.ClientSecret = c.String("secret") + } + + if c.IsSet("auto-discover-url") { + oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url") + } + + if c.IsSet("icon-url") { + oAuth2Config.IconURL = c.String("icon-url") + } + + if c.IsSet("scopes") { + oAuth2Config.Scopes = c.StringSlice("scopes") + } + + if c.IsSet("required-claim-name") { + oAuth2Config.RequiredClaimName = c.String("required-claim-name") + } + if c.IsSet("required-claim-value") { + oAuth2Config.RequiredClaimValue = c.String("required-claim-value") + } + + if c.IsSet("group-claim-name") { + oAuth2Config.GroupClaimName = c.String("group-claim-name") + } + if c.IsSet("admin-group") { + oAuth2Config.AdminGroup = c.String("admin-group") + } + if c.IsSet("restricted-group") { + oAuth2Config.RestrictedGroup = c.String("restricted-group") + } + if c.IsSet("group-team-map") { + oAuth2Config.GroupTeamMap = c.String("group-team-map") + } + if c.IsSet("group-team-map-removal") { + oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") + } + if c.IsSet("ssh-public-key-claim-name") { + oAuth2Config.SSHPublicKeyClaimName = c.String("ssh-public-key-claim-name") + } + if c.IsSet("full-name-claim-name") { + oAuth2Config.FullNameClaimName = c.String("full-name-claim-name") + } + + // update custom URL mapping + customURLMapping := &oauth2.CustomURLMapping{} + + if oAuth2Config.CustomURLMapping != nil { + customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL + customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL + customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL + customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL + customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant + } + if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") { + customURLMapping.TokenURL = c.String("custom-token-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") { + customURLMapping.AuthURL = c.String("custom-auth-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") { + customURLMapping.ProfileURL = c.String("custom-profile-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") { + customURLMapping.EmailURL = c.String("custom-email-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") { + customURLMapping.Tenant = c.String("custom-tenant-id") + } + + oAuth2Config.CustomURLMapping = customURLMapping + source.Cfg = oAuth2Config + source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "") + return a.updateAuthSource(ctx, source) +} diff --git a/cmd/admin_auth_oauth_test.go b/cmd/admin_auth_oauth_test.go new file mode 100644 index 0000000..c16dd96 --- /dev/null +++ b/cmd/admin_auth_oauth_test.go @@ -0,0 +1,343 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/services/auth/source/oauth2" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestAddOauth(t *testing.T) { + testCases := []struct { + name string + args []string + source *auth_model.Source + errMsg string + }{ + { + name: "valid config", + args: []string{ + "--name", "test", + "--provider", "github", + "--key", "some_key", + "--secret", "some_secret", + }, + source: &auth_model.Source{ + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + Scopes: []string{}, + Provider: "github", + ClientID: "some_key", + ClientSecret: "some_secret", + }, + TwoFactorPolicy: "", + }, + }, + { + name: "valid config with openid connect", + args: []string{ + "--name", "test", + "--provider", "openidConnect", + "--key", "some_key", + "--secret", "some_secret", + "--auto-discover-url", "https://example.com", + }, + source: &auth_model.Source{ + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + Scopes: []string{}, + Provider: "openidConnect", + ClientID: "some_key", + ClientSecret: "some_secret", + OpenIDConnectAutoDiscoveryURL: "https://example.com", + }, + TwoFactorPolicy: "", + }, + }, + { + name: "valid config with options", + args: []string{ + "--name", "test", + "--provider", "gitlab", + "--key", "some_key", + "--secret", "some_secret", + "--use-custom-urls", "true", + "--custom-token-url", "https://example.com/token", + "--custom-auth-url", "https://example.com/auth", + "--custom-profile-url", "https://example.com/profile", + "--custom-email-url", "https://example.com/email", + "--custom-tenant-id", "some_tenant", + "--icon-url", "https://example.com/icon", + "--scopes", "scope1,scope2", + "--skip-local-2fa", "true", + "--required-claim-name", "claim_name", + "--required-claim-value", "claim_value", + "--group-claim-name", "group_name", + "--admin-group", "admin", + "--restricted-group", "restricted", + "--group-team-map", `{"group1": [1,2]}`, + "--group-team-map-removal=true", + "--ssh-public-key-claim-name", "attr_ssh_pub_key", + "--full-name-claim-name", "attr_full_name", + }, + source: &auth_model.Source{ + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "gitlab", + ClientID: "some_key", + ClientSecret: "some_secret", + CustomURLMapping: &oauth2.CustomURLMapping{ + TokenURL: "https://example.com/token", + AuthURL: "https://example.com/auth", + ProfileURL: "https://example.com/profile", + EmailURL: "https://example.com/email", + Tenant: "some_tenant", + }, + IconURL: "https://example.com/icon", + Scopes: []string{"scope1", "scope2"}, + RequiredClaimName: "claim_name", + RequiredClaimValue: "claim_value", + GroupClaimName: "group_name", + AdminGroup: "admin", + RestrictedGroup: "restricted", + GroupTeamMap: `{"group1": [1,2]}`, + GroupTeamMapRemoval: true, + SSHPublicKeyClaimName: "attr_ssh_pub_key", + FullNameClaimName: "attr_full_name", + }, + TwoFactorPolicy: "skip", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var createdSource *auth_model.Source + a := &authService{ + initDB: func(ctx context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, source *auth_model.Source) error { + createdSource = source + return nil + }, + } + + app := &cli.Command{ + Flags: microcmdAuthAddOauth().Flags, + Action: a.runAddOauth, + } + + args := []string{"oauth-test"} + args = append(args, tc.args...) + + err := app.Run(t.Context(), args) + + if tc.errMsg != "" { + assert.EqualError(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.source, createdSource) + } + }) + } +} + +func TestUpdateOauth(t *testing.T) { + testCases := []struct { + name string + args []string + id int64 + existingAuthSource *auth_model.Source + authSource *auth_model.Source + errMsg string + }{ + { + name: "missing id", + args: []string{ + "--name", "test", + }, + errMsg: "--id flag is missing", + }, + { + name: "valid config", + id: 1, + existingAuthSource: &auth_model.Source{ + ID: 1, + Type: auth_model.OAuth2, + Name: "old name", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "github", + ClientID: "old_key", + ClientSecret: "old_secret", + }, + TwoFactorPolicy: "", + }, + args: []string{ + "--id", "1", + "--name", "test", + "--provider", "gitlab", + "--key", "new_key", + "--secret", "new_secret", + }, + authSource: &auth_model.Source{ + ID: 1, + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "gitlab", + ClientID: "new_key", + ClientSecret: "new_secret", + CustomURLMapping: &oauth2.CustomURLMapping{}, + }, + TwoFactorPolicy: "", + }, + }, + { + name: "valid config with options", + id: 1, + existingAuthSource: &auth_model.Source{ + ID: 1, + Type: auth_model.OAuth2, + Name: "old name", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "gitlab", + ClientID: "old_key", + ClientSecret: "old_secret", + CustomURLMapping: &oauth2.CustomURLMapping{ + TokenURL: "https://old.example.com/token", + AuthURL: "https://old.example.com/auth", + ProfileURL: "https://old.example.com/profile", + EmailURL: "https://old.example.com/email", + Tenant: "old_tenant", + }, + IconURL: "https://old.example.com/icon", + Scopes: []string{"old_scope1", "old_scope2"}, + RequiredClaimName: "old_claim_name", + RequiredClaimValue: "old_claim_value", + GroupClaimName: "old_group_name", + AdminGroup: "old_admin", + RestrictedGroup: "old_restricted", + GroupTeamMap: `{"old_group1": [1,2]}`, + GroupTeamMapRemoval: true, + SSHPublicKeyClaimName: "old_ssh_pub_key", + FullNameClaimName: "old_full_name", + }, + TwoFactorPolicy: "", + }, + args: []string{ + "--id", "1", + "--name", "test", + "--provider", "github", + "--key", "new_key", + "--secret", "new_secret", + "--use-custom-urls", "true", + "--custom-token-url", "https://example.com/token", + "--custom-auth-url", "https://example.com/auth", + "--custom-profile-url", "https://example.com/profile", + "--custom-email-url", "https://example.com/email", + "--custom-tenant-id", "new_tenant", + "--icon-url", "https://example.com/icon", + "--scopes", "scope1,scope2", + "--skip-local-2fa=true", + "--required-claim-name", "claim_name", + "--required-claim-value", "claim_value", + "--group-claim-name", "group_name", + "--admin-group", "admin", + "--restricted-group", "restricted", + "--group-team-map", `{"group1": [1,2]}`, + "--group-team-map-removal=false", + "--ssh-public-key-claim-name", "new_ssh_pub_key", + "--full-name-claim-name", "new_full_name", + }, + authSource: &auth_model.Source{ + ID: 1, + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "github", + ClientID: "new_key", + ClientSecret: "new_secret", + CustomURLMapping: &oauth2.CustomURLMapping{ + TokenURL: "https://example.com/token", + AuthURL: "https://example.com/auth", + ProfileURL: "https://example.com/profile", + EmailURL: "https://example.com/email", + Tenant: "new_tenant", + }, + IconURL: "https://example.com/icon", + Scopes: []string{"scope1", "scope2"}, + RequiredClaimName: "claim_name", + RequiredClaimValue: "claim_value", + GroupClaimName: "group_name", + AdminGroup: "admin", + RestrictedGroup: "restricted", + GroupTeamMap: `{"group1": [1,2]}`, + GroupTeamMapRemoval: false, + SSHPublicKeyClaimName: "new_ssh_pub_key", + FullNameClaimName: "new_full_name", + }, + TwoFactorPolicy: "skip", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + a := &authService{ + initDB: func(ctx context.Context) error { + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth_model.Source, error) { + return &auth_model.Source{ + ID: 1, + Type: auth_model.OAuth2, + Name: "test", + IsActive: true, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + }, + TwoFactorPolicy: "skip", + }, nil + }, + updateAuthSource: func(ctx context.Context, source *auth_model.Source) error { + assert.Equal(t, tc.authSource, source) + return nil + }, + } + + app := &cli.Command{ + Flags: microcmdAuthUpdateOauth().Flags, + Action: a.runUpdateOauth, + } + + args := []string{"oauth-test"} + args = append(args, tc.args...) + + err := app.Run(t.Context(), args) + + if tc.errMsg != "" { + assert.EqualError(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/admin_auth_smtp.go b/cmd/admin_auth_smtp.go new file mode 100644 index 0000000..5d99b5e --- /dev/null +++ b/cmd/admin_auth_smtp.go @@ -0,0 +1,199 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "strings" + + auth_model "gitea.dev/models/auth" + "gitea.dev/modules/util" + "gitea.dev/services/auth/source/smtp" + + "github.com/urfave/cli/v3" +) + +func smtpCLIFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "Application Name", + }, + &cli.StringFlag{ + Name: "auth-type", + Value: "PLAIN", + Usage: "SMTP Authentication Type (PLAIN/LOGIN/CRAM-MD5) default PLAIN", + }, + &cli.StringFlag{ + Name: "host", + Value: "", + Usage: "SMTP Host", + }, + &cli.IntFlag{ + Name: "port", + Usage: "SMTP Port", + }, + &cli.BoolFlag{ + Name: "force-smtps", + Usage: "SMTPS is always used on port 465. Set this to force SMTPS on other ports.", + }, + &cli.BoolFlag{ + Name: "skip-verify", + Usage: "Skip TLS verify.", + }, + &cli.StringFlag{ + Name: "helo-hostname", + Value: "", + Usage: "Hostname sent with HELO. Leave blank to send current hostname", + }, + &cli.BoolFlag{ + Name: "disable-helo", + Usage: "Disable SMTP helo.", + }, + &cli.StringFlag{ + Name: "allowed-domains", + Value: "", + Usage: "Leave empty to allow all domains. Separate multiple domains with a comma (',')", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Skip 2FA to log on.", + }, + &cli.BoolFlag{ + Name: "active", + Usage: "This Authentication Source is Activated.", + Value: true, + }, + } +} + +func microcmdAuthUpdateSMTP() *cli.Command { + return &cli.Command{ + Name: "update-smtp", + Usage: "Update existing SMTP authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().runUpdateSMTP(ctx, cmd) + }, + Flags: append(smtpCLIFlags()[:1], append([]cli.Flag{&cli.Int64Flag{ + Name: "id", + Usage: "ID of authentication source", + }}, smtpCLIFlags()[1:]...)...), + } +} + +func microcmdAuthAddSMTP() *cli.Command { + return &cli.Command{ + Name: "add-smtp", + Usage: "Add new SMTP authentication source", + Action: func(ctx context.Context, cmd *cli.Command) error { + return newAuthService().runAddSMTP(ctx, cmd) + }, + Flags: smtpCLIFlags(), + } +} + +func parseSMTPConfig(c *cli.Command, conf *smtp.Source) error { + if c.IsSet("auth-type") { + conf.Auth = c.String("auth-type") + validAuthTypes := []string{"PLAIN", "LOGIN", "CRAM-MD5"} + if !util.SliceContainsString(validAuthTypes, strings.ToUpper(c.String("auth-type"))) { + return errors.New("Auth must be one of PLAIN/LOGIN/CRAM-MD5") + } + conf.Auth = c.String("auth-type") + } + if c.IsSet("host") { + conf.Host = c.String("host") + } + if c.IsSet("port") { + conf.Port = c.Int("port") + } + if c.IsSet("allowed-domains") { + conf.AllowedDomains = c.String("allowed-domains") + } + if c.IsSet("force-smtps") { + conf.ForceSMTPS = c.Bool("force-smtps") + } + if c.IsSet("skip-verify") { + conf.SkipVerify = c.Bool("skip-verify") + } + if c.IsSet("helo-hostname") { + conf.HeloHostname = c.String("helo-hostname") + } + if c.IsSet("disable-helo") { + conf.DisableHelo = c.Bool("disable-helo") + } + return nil +} + +func (a *authService) runAddSMTP(ctx context.Context, c *cli.Command) error { + if err := a.initDB(ctx); err != nil { + return err + } + + if !c.IsSet("name") || len(c.String("name")) == 0 { + return errors.New("name must be set") + } + if !c.IsSet("host") || len(c.String("host")) == 0 { + return errors.New("host must be set") + } + if !c.IsSet("port") { + return errors.New("port must be set") + } + active := true + if c.IsSet("active") { + active = c.Bool("active") + } + + var smtpConfig smtp.Source + if err := parseSMTPConfig(c, &smtpConfig); err != nil { + return err + } + + // If not set default to PLAIN + if len(smtpConfig.Auth) == 0 { + smtpConfig.Auth = "PLAIN" + } + + return a.createAuthSource(ctx, &auth_model.Source{ + Type: auth_model.SMTP, + Name: c.String("name"), + IsActive: active, + Cfg: &smtpConfig, + TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""), + }) +} + +func (a *authService) runUpdateSMTP(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + if err := a.initDB(ctx); err != nil { + return err + } + + source, err := a.getAuthSourceOfType(ctx, c.Int64("id"), auth_model.SMTP) + if err != nil { + return err + } + smtpConfig := auth_model.MustSourceCfg[*smtp.Source](source) + + if err := parseSMTPConfig(c, smtpConfig); err != nil { + return err + } + + if c.IsSet("name") { + source.Name = c.String("name") + } + + if c.IsSet("active") { + source.IsActive = c.Bool("active") + } + + source.Cfg = smtpConfig + source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "") + return a.updateAuthSource(ctx, source) +} diff --git a/cmd/admin_auth_smtp_test.go b/cmd/admin_auth_smtp_test.go new file mode 100644 index 0000000..2b890f1 --- /dev/null +++ b/cmd/admin_auth_smtp_test.go @@ -0,0 +1,271 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/services/auth/source/smtp" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestAddSMTP(t *testing.T) { + testCases := []struct { + name string + args []string + source *auth_model.Source + errMsg string + }{ + { + name: "missing name", + args: []string{ + "--host", "localhost", + "--port", "25", + }, + errMsg: "name must be set", + }, + { + name: "missing host", + args: []string{ + "--name", "test", + "--port", "25", + }, + errMsg: "host must be set", + }, + { + name: "missing port", + args: []string{ + "--name", "test", + "--host", "localhost", + }, + errMsg: "port must be set", + }, + { + name: "valid config", + args: []string{ + "--name", "test", + "--host", "localhost", + "--port", "25", + }, + source: &auth_model.Source{ + Type: auth_model.SMTP, + Name: "test", + IsActive: true, + Cfg: &smtp.Source{ + Auth: "PLAIN", + Host: "localhost", + Port: 25, + }, + TwoFactorPolicy: "", + }, + }, + { + name: "valid config with options", + args: []string{ + "--name", "test", + "--host", "localhost", + "--port", "25", + "--auth-type", "LOGIN", + "--force-smtps", + "--skip-verify", + "--helo-hostname", "example.com", + "--disable-helo=true", + "--allowed-domains", "example.com,example.org", + "--skip-local-2fa", + "--active=false", + }, + source: &auth_model.Source{ + Type: auth_model.SMTP, + Name: "test", + IsActive: false, + Cfg: &smtp.Source{ + Auth: "LOGIN", + Host: "localhost", + Port: 25, + ForceSMTPS: true, + SkipVerify: true, + HeloHostname: "example.com", + DisableHelo: true, + AllowedDomains: "example.com,example.org", + }, + TwoFactorPolicy: "skip", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + a := &authService{ + initDB: func(ctx context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, source *auth_model.Source) error { + assert.Equal(t, tc.source, source) + return nil + }, + } + + cmd := &cli.Command{ + Flags: microcmdAuthAddSMTP().Flags, + Action: a.runAddSMTP, + } + + args := []string{"smtp-test"} + args = append(args, tc.args...) + + t.Log(args) + err := cmd.Run(t.Context(), args) + + if tc.errMsg != "" { + assert.EqualError(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUpdateSMTP(t *testing.T) { + testCases := []struct { + name string + args []string + existingAuthSource *auth_model.Source + authSource *auth_model.Source + errMsg string + }{ + { + name: "missing id", + args: []string{ + "--name", "test", + "--host", "localhost", + "--port", "25", + }, + errMsg: "--id flag is missing", + }, + { + name: "valid config", + existingAuthSource: &auth_model.Source{ + ID: 1, + Type: auth_model.SMTP, + Name: "old name", + IsActive: true, + Cfg: &smtp.Source{ + Auth: "PLAIN", + Host: "old host", + Port: 26, + }, + }, + args: []string{ + "--id", "1", + "--name", "test", + "--host", "localhost", + "--port", "25", + }, + authSource: &auth_model.Source{ + ID: 1, + Type: auth_model.SMTP, + Name: "test", + IsActive: true, + Cfg: &smtp.Source{ + Auth: "PLAIN", + Host: "localhost", + Port: 25, + }, + }, + }, + { + name: "valid config with options", + existingAuthSource: &auth_model.Source{ + ID: 1, + Type: auth_model.SMTP, + Name: "old name", + IsActive: true, + Cfg: &smtp.Source{ + Auth: "PLAIN", + Host: "old host", + Port: 26, + HeloHostname: "old.example.com", + AllowedDomains: "old.example.com", + }, + TwoFactorPolicy: "", + }, + args: []string{ + "--id", "1", + "--name", "test", + "--host", "localhost", + "--port", "25", + "--auth-type", "LOGIN", + "--force-smtps", + "--skip-verify", + "--helo-hostname", "example.com", + "--disable-helo", + "--allowed-domains", "example.com,example.org", + "--skip-local-2fa", + "--active=false", + }, + authSource: &auth_model.Source{ + ID: 1, + Type: auth_model.SMTP, + Name: "test", + IsActive: false, + Cfg: &smtp.Source{ + Auth: "LOGIN", + Host: "localhost", + Port: 25, + ForceSMTPS: true, + SkipVerify: true, + HeloHostname: "example.com", + DisableHelo: true, + AllowedDomains: "example.com,example.org", + }, + TwoFactorPolicy: "skip", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + a := &authService{ + initDB: func(ctx context.Context) error { + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth_model.Source, error) { + return &auth_model.Source{ + ID: 1, + Type: auth_model.SMTP, + Name: "test", + IsActive: true, + Cfg: &smtp.Source{ + Auth: "PLAIN", + }, + }, nil + }, + + updateAuthSource: func(ctx context.Context, source *auth_model.Source) error { + assert.Equal(t, tc.authSource, source) + return nil + }, + } + + app := &cli.Command{ + Flags: microcmdAuthUpdateSMTP().Flags, + Action: a.runUpdateSMTP, + } + args := []string{"smtp-tests"} + args = append(args, tc.args...) + + err := app.Run(t.Context(), args) + + if tc.errMsg != "" { + assert.EqualError(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/admin_regenerate.go b/cmd/admin_regenerate.go new file mode 100644 index 0000000..89b7bf3 --- /dev/null +++ b/cmd/admin_regenerate.go @@ -0,0 +1,44 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/modules/graceful" + asymkey_service "gitea.dev/services/asymkey" + repo_service "gitea.dev/services/repository" + + "github.com/urfave/cli/v3" +) + +func newRegenerateHooksCommand() *cli.Command { + return &cli.Command{ + Name: "hooks", + Usage: "Regenerate git-hooks", + Action: runRegenerateHooks, + } +} + +func newRegenerateKeysCommand() *cli.Command { + return &cli.Command{ + Name: "keys", + Usage: "Regenerate authorized_keys file", + Action: runRegenerateKeys, + } +} + +func runRegenerateHooks(ctx context.Context, _ *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext()) +} + +func runRegenerateKeys(ctx context.Context, _ *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + return asymkey_service.RewriteAllPublicKeys(ctx) +} diff --git a/cmd/admin_user.go b/cmd/admin_user.go new file mode 100644 index 0000000..471154b --- /dev/null +++ b/cmd/admin_user.go @@ -0,0 +1,29 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +func newUserCommand() *cli.Command { + return &cli.Command{ + Name: "user", + Usage: "Modify users", + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + return cliAuditContext(ctx), nil + }, + Commands: []*cli.Command{ + microcmdUserCreate(), + newUserListCommand(), + microcmdUserChangePassword(), + microcmdUserDelete(), + newUserGenerateAccessTokenCommand(), + microcmdUserMustChangePassword(), + microcmdUserDisableTwoFactor(), + }, + } +} diff --git a/cmd/admin_user_change_password.go b/cmd/admin_user_change_password.go new file mode 100644 index 0000000..46e0588 --- /dev/null +++ b/cmd/admin_user_change_password.go @@ -0,0 +1,78 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/auth/password" + "gitea.dev/modules/optional" + "gitea.dev/modules/setting" + user_service "gitea.dev/services/user" + + "github.com/urfave/cli/v3" +) + +func microcmdUserChangePassword() *cli.Command { + return &cli.Command{ + Name: "change-password", + Usage: "Change a user's password", + Action: runChangePassword, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "The user to change password for", + Required: true, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "New password to set for user", + Required: true, + }, + &cli.BoolFlag{ + Name: "must-change-password", + Usage: "User must change password (can be disabled by --must-change-password=false)", + Value: true, + }, + }, + } +} + +func runChangePassword(ctx context.Context, c *cli.Command) error { + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + user, err := user_model.GetUserByName(ctx, c.String("username")) + if err != nil { + return err + } + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(c.String("password")), + MustChangePassword: optional.Some(c.Bool("must-change-password")), + } + if err := user_service.UpdateAuth(ctx, user, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + return fmt.Errorf("password is not long enough, needs to be at least %d characters", setting.MinPasswordLength) + case errors.Is(err, password.ErrComplexity): + return errors.New("password does not meet complexity requirements") + case errors.Is(err, password.ErrIsPwned): + return errors.New("the password is in a list of stolen passwords previously exposed in public data breaches, please try again with a different password, to see more details: https://haveibeenpwned.com/Passwords") + default: + return err + } + } + + cprintf(c, "%s's password has been successfully updated!\n", user.Name) + return nil +} diff --git a/cmd/admin_user_change_password_test.go b/cmd/admin_user_change_password_test.go new file mode 100644 index 0000000..50fd8d9 --- /dev/null +++ b/cmd/admin_user_change_password_test.go @@ -0,0 +1,94 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChangePasswordCommand(t *testing.T) { + ctx := t.Context() + + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + }() + + t.Run("change password successfully", func(t *testing.T) { + // defer func() { + // require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + // }() + // Prepare test user + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + err := microcmdUserCreate().Run(ctx, []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}) + require.NoError(t, err) + + // load test user + userBase := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + + // Change the password + err = microcmdUserChangePassword().Run(ctx, []string{"change-password", "--username", "testuser", "--password", "newpassword"}) + require.NoError(t, err) + + // Verify the password has been changed + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.NotEqual(t, userBase.Passwd, user.Passwd) + assert.NotEqual(t, userBase.Salt, user.Salt) + + // Additional check for must-change-password flag + require.NoError(t, microcmdUserChangePassword().Run(ctx, []string{"change-password", "--username", "testuser", "--password", "anotherpassword", "--must-change-password=false"})) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.False(t, user.MustChangePassword) + + require.NoError(t, microcmdUserChangePassword().Run(ctx, []string{"change-password", "--username", "testuser", "--password", "yetanotherpassword", "--must-change-password"})) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, user.MustChangePassword) + }) + + t.Run("failure cases", func(t *testing.T) { + testCases := []struct { + name string + args []string + expectedErr string + }{ + { + name: "user does not exist", + args: []string{"change-password", "--username", "nonexistentuser", "--password", "newpassword"}, + expectedErr: "user does not exist", + }, + { + name: "missing username", + args: []string{"change-password", "--password", "newpassword"}, + expectedErr: `"username" not set`, + }, + { + name: "missing password", + args: []string{"change-password", "--username", "testuser"}, + expectedErr: `"password" not set`, + }, + { + name: "too short password", + args: []string{"change-password", "--username", "testuser", "--password", "1"}, + expectedErr: "password is not long enough", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := microcmdUserChangePassword() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, tc.args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + } + }) +} diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go new file mode 100644 index 0000000..2db926d --- /dev/null +++ b/cmd/admin_user_create.go @@ -0,0 +1,242 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + pwd "gitea.dev/modules/auth/password" + "gitea.dev/modules/optional" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func microcmdUserCreate() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create a new user in database", + Action: runCreateUser, + MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{ + { + Flags: [][]cli.Flag{ + { + &cli.StringFlag{ + Name: "name", + Usage: "Username. DEPRECATED: use username instead", + }, + &cli.StringFlag{ + Name: "username", + Usage: "Username", + }, + }, + }, + Required: true, + }, + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "user-type", + Usage: "Set user's type: individual or bot", + Value: "individual", + }, + &cli.StringFlag{ + Name: "password", + Usage: "User password", + }, + &cli.StringFlag{ + Name: "email", + Usage: "User email address", + Required: true, + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "User is an admin", + }, + &cli.BoolFlag{ + Name: "random-password", + Usage: "Generate a random password for the user", + }, + &cli.BoolFlag{ + Name: "must-change-password", + Usage: "User must change password after initial login, defaults to true for all users except the first one (can be disabled by --must-change-password=false)", + HideDefault: true, + }, + &cli.IntFlag{ + Name: "random-password-length", + Usage: "Length of the random password to be generated", + Value: 12, + }, + &cli.BoolFlag{ + Name: "access-token", + Usage: "Generate access token for the user", + }, + &cli.StringFlag{ + Name: "access-token-name", + Usage: `Name of the generated access token`, + Value: "gitea-admin", + }, + &cli.StringFlag{ + Name: "access-token-scopes", + Usage: `Scopes of the generated access token, comma separated. Examples: "all", "public-only,read:issue", "write:repository,write:user"`, + Value: "all", + }, + &cli.BoolFlag{ + Name: "restricted", + Usage: "Make a restricted user account", + }, + &cli.StringFlag{ + Name: "fullname", + Usage: `The full, human-readable name of the user`, + }, + }, + } +} + +func runCreateUser(ctx context.Context, c *cli.Command) error { + // this command highly depends on the many setting options (create org, visibility, etc.), so it must have a full setting load first + // duplicate setting loading should be safe at the moment, but it should be refactored & improved in the future. + setting.LoadSettings() + + userTypes := map[string]user_model.UserType{ + "individual": user_model.UserTypeIndividual, + "bot": user_model.UserTypeBot, + } + userType, ok := userTypes[c.String("user-type")] + if !ok { + return fmt.Errorf("invalid user type: %s", c.String("user-type")) + } + if userType != user_model.UserTypeIndividual { + // Some other commands like "change-password" also only support individual users. + // It needs to clarify the "password" behavior for bot users in the future. + // At the moment, we do not allow setting password for bot users. + if c.IsSet("password") || c.IsSet("random-password") { + return errors.New("password can only be set for individual users") + } + } + + if c.IsSet("password") && c.IsSet("random-password") { + return errors.New("cannot set both -random-password and -password flags") + } + + var username string + if c.IsSet("username") { + username = c.String("username") + } else { + username = c.String("name") + _, _ = fmt.Fprintf(c.ErrWriter, "--name flag is deprecated. Use --username instead.\n") + } + + if !setting.IsInTesting { + // FIXME: need to refactor the "initDB" related code later + // it doesn't make sense to call it in (almost) every command action function + if err := initDB(ctx); err != nil { + return err + } + } + + var password string + if c.IsSet("password") { + password = c.String("password") + } else if c.IsSet("random-password") { + var err error + password, err = pwd.Generate(c.Int("random-password-length")) + if err != nil { + return err + } + // codeql[disable-next-line=go/clear-text-logging] + cprintf(c, "generated random password is '%s'\n", password) + } else if userType == user_model.UserTypeIndividual { + return errors.New("must set either password or random-password flag") + } + + isAdmin := c.Bool("admin") + // Only local, existing, regular users should be forced to update their password. Bot users for example are non-interactive + mustChangePassword := userType == user_model.UserTypeIndividual + if c.IsSet("must-change-password") { + if userType != user_model.UserTypeIndividual { + return errors.New("must-change-password flag can only be set for individual users") + } + // if the flag is set, use the value provided by the user + mustChangePassword = c.Bool("must-change-password") + } else if userType == user_model.UserTypeIndividual { + // check whether there are users in the database + hasUserRecord, err := db.IsTableNotEmpty(&user_model.User{}) + if err != nil { + return fmt.Errorf("IsTableNotEmpty: %w", err) + } + if !hasUserRecord { + // if this is the first one being created, don't force to change password (keep the old behavior) + mustChangePassword = false + } + } + + restricted := optional.None[bool]() + + if c.IsSet("restricted") { + restricted = optional.Some(c.Bool("restricted")) + } + + // default user visibility in app.ini + visibility := setting.Service.DefaultUserVisibilityMode + + u := &user_model.User{ + Name: username, + Email: c.String("email"), + IsAdmin: isAdmin, + Type: userType, + Passwd: password, + MustChangePassword: mustChangePassword, + Visibility: visibility, + FullName: c.String("fullname"), + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(true), + IsRestricted: restricted, + } + + var accessTokenName string + var accessTokenScope auth_model.AccessTokenScope + if c.IsSet("access-token") { + accessTokenName = strings.TrimSpace(c.String("access-token-name")) + if accessTokenName == "" { + return errors.New("access-token-name cannot be empty") + } + var err error + accessTokenScope, err = auth_model.AccessTokenScope(c.String("access-token-scopes")).Normalize() + if err != nil { + return fmt.Errorf("invalid access token scope provided: %w", err) + } + if !accessTokenScope.HasPermissionScope() { + return errors.New("access token does not have any permission") + } + } else if c.IsSet("access-token-name") || c.IsSet("access-token-scopes") { + return errors.New("access-token-name and access-token-scopes flags are only valid when access-token flag is set") + } + + // arguments should be prepared before creating the user & access token, in case there is anything wrong + + // create the user + if err := user_model.CreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil { + return fmt.Errorf("CreateUser: %w", err) + } + cprintf(c, "New user '%s' has been successfully created!\n", username) + + // create the access token + if accessTokenScope != "" { + t := &auth_model.AccessToken{Name: accessTokenName, UID: u.ID, Scope: accessTokenScope} + if err := auth_model.NewAccessToken(ctx, t); err != nil { + return err + } + cprintf(c, "Access token was successfully created... %s\n", t.Token) + } + return nil +} diff --git a/cmd/admin_user_create_test.go b/cmd/admin_user_create_test.go new file mode 100644 index 0000000..ece2e88 --- /dev/null +++ b/cmd/admin_user_create_test.go @@ -0,0 +1,135 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "strings" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdminUserCreate(t *testing.T) { + reset := func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) + require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.AccessToken{})) + } + + t.Run("MustChangePassword", func(t *testing.T) { + type check struct { + IsAdmin bool + MustChangePassword bool + } + + createCheck := func(name, args string) check { + require.NoError(t, microcmdUserCreate().Run(t.Context(), strings.Fields(fmt.Sprintf("create --username %s --email %s@gitea.local %s --password foobar", name, name, args)))) + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: name}) + return check{IsAdmin: u.IsAdmin, MustChangePassword: u.MustChangePassword} + } + reset() + assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u", ""), "first non-admin user doesn't need to change password") + + reset() + assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u", "--admin"), "first admin user doesn't need to change password") + + reset() + assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u", "--admin --must-change-password")) + assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u2", "--admin")) + assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u3", "--admin --must-change-password=false")) + assert.Equal(t, check{IsAdmin: false, MustChangePassword: true}, createCheck("u4", "")) + assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u5", "--must-change-password=false")) + }) + + createUser := func(name string, args ...string) error { + return microcmdUserCreate().Run(t.Context(), append([]string{"create", "--username", name, "--email", name + "@gitea.local"}, args...)) + } + + t.Run("UserType", func(t *testing.T) { + reset() + assert.ErrorContains(t, createUser("u", "--user-type", "invalid"), "invalid user type") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--password", "123"), "can only be set for individual users") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--must-change-password"), "can only be set for individual users") + + assert.NoError(t, createUser("u", "--user-type", "bot")) + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"}) + assert.Equal(t, user_model.UserTypeBot, u.Type) + assert.Empty(t, u.Passwd) + assert.False(t, u.MustChangePassword, "bot users should not be forced to change password") + }) + + t.Run("AccessToken", func(t *testing.T) { + // no generated access token + reset() + assert.NoError(t, createUser("u", "--random-password")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + + // using "--access-token" only means "all" access + reset() + assert.NoError(t, createUser("u", "--random-password", "--access-token")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 1, unittest.GetCount(t, &auth_model.AccessToken{})) + accessToken := unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{Name: "gitea-admin"}) + hasScopes, err := accessToken.Scope.HasScope(auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + assert.NoError(t, err) + assert.True(t, hasScopes) + + // using "--access-token" with name & scopes + reset() + assert.NoError(t, createUser("u", "--random-password", "--access-token", "--access-token-name", "new-token-name", "--access-token-scopes", "read:issue,read:user")) + assert.Equal(t, 1, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 1, unittest.GetCount(t, &auth_model.AccessToken{})) + accessToken = unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{Name: "new-token-name"}) + hasScopes, err = accessToken.Scope.HasScope(auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadUser) + assert.NoError(t, err) + assert.True(t, hasScopes) + hasScopes, err = accessToken.Scope.HasScope(auth_model.AccessTokenScopeWriteAdmin, auth_model.AccessTokenScopeWriteRepository) + assert.NoError(t, err) + assert.False(t, hasScopes) + + // using "--access-token-name" without "--access-token" + reset() + err = createUser("u", "--random-password", "--access-token-name", "new-token-name") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access-token-name and access-token-scopes flags are only valid when access-token flag is set") + + // using "--access-token-scopes" without "--access-token" + reset() + err = createUser("u", "--random-password", "--access-token-scopes", "read:issue") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access-token-name and access-token-scopes flags are only valid when access-token flag is set") + + // empty permission + reset() + err = createUser("u", "--random-password", "--access-token", "--access-token-scopes", "public-only") + assert.Equal(t, 0, unittest.GetCount(t, &user_model.User{})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.AccessToken{})) + assert.ErrorContains(t, err, "access token does not have any permission") + }) + + t.Run("UserFields", func(t *testing.T) { + reset() + assert.NoError(t, createUser("u-FullNameWithSpace", "--random-password", "--fullname", "First O'Middle Last")) + unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: "u-FullNameWithSpace", + LowerName: "u-fullnamewithspace", + FullName: "First O'Middle Last", + Email: "u-FullNameWithSpace@gitea.local", + }) + + assert.NoError(t, createUser("u-FullNameEmpty", "--random-password", "--fullname", "")) + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u-fullnameempty"}) + assert.Empty(t, u.FullName) + }) +} diff --git a/cmd/admin_user_delete.go b/cmd/admin_user_delete.go new file mode 100644 index 0000000..cc70fe7 --- /dev/null +++ b/cmd/admin_user_delete.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + "gitea.dev/modules/storage" + user_service "gitea.dev/services/user" + + "github.com/urfave/cli/v3" +) + +func microcmdUserDelete() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "Delete specific user by id, name or email", + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "id", + Usage: "ID of user of the user to delete", + }, + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username of the user to delete", + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Email of the user to delete", + }, + &cli.BoolFlag{ + Name: "purge", + Usage: "Purge user, all their repositories, organizations and comments", + }, + }, + Action: runDeleteUser, + } +} + +func runDeleteUser(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") && !c.IsSet("username") && !c.IsSet("email") { + return errors.New("You must provide the id, username or email of a user to delete") + } + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + if err := storage.Init(); err != nil { + return err + } + + var err error + var user *user_model.User + if c.IsSet("email") { + user, err = user_model.GetUserByEmail(ctx, c.String("email")) + } else if c.IsSet("username") { + user, err = user_model.GetUserByName(ctx, c.String("username")) + } else { + user, err = user_model.GetUserByID(ctx, c.Int64("id")) + } + if err != nil { + return err + } + if c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) { + return fmt.Errorf("the user %s who has email %s does not match the provided username %s", user.Name, c.String("email"), c.String("username")) + } + + if c.IsSet("id") && user.ID != c.Int64("id") { + return fmt.Errorf("the user %s does not match the provided id %d", user.Name, c.Int64("id")) + } + + return user_service.DeleteUser(ctx, user, c.Bool("purge")) +} diff --git a/cmd/admin_user_delete_test.go b/cmd/admin_user_delete_test.go new file mode 100644 index 0000000..4fbd043 --- /dev/null +++ b/cmd/admin_user_delete_test.go @@ -0,0 +1,111 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strconv" + "strings" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/require" +) + +func TestAdminUserDelete(t *testing.T) { + ctx := t.Context() + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) + require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.AccessToken{})) + }() + + setupTestUser := func(t *testing.T) { + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + err := microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}) + require.NoError(t, err) + } + + t.Run("delete user by id", func(t *testing.T) { + setupTestUser(t) + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + err := microcmdUserDelete().Run(ctx, []string{"delete-test", "--id", strconv.FormatInt(u.ID, 10)}) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + }) + t.Run("delete user by username", func(t *testing.T) { + setupTestUser(t) + + err := microcmdUserDelete().Run(ctx, []string{"delete-test", "--username", "testuser"}) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + }) + t.Run("delete user by email", func(t *testing.T) { + setupTestUser(t) + + err := microcmdUserDelete().Run(ctx, []string{"delete-test", "--email", "testuser@gitea.local"}) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + }) + t.Run("delete user by all 3 attributes", func(t *testing.T) { + setupTestUser(t) + + u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + err := microcmdUserDelete().Run(ctx, []string{"delete", "--id", strconv.FormatInt(u.ID, 10), "--username", "testuser", "--email", "testuser@gitea.local"}) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + }) +} + +func TestAdminUserDeleteFailure(t *testing.T) { + testCases := []struct { + name string + args []string + expectedErr string + }{ + { + name: "no user to delete", + args: []string{"delete", "--username", "nonexistentuser"}, + expectedErr: "user does not exist", + }, + { + name: "user exists but provided username does not match", + args: []string{"delete", "--email", "testuser@gitea.local", "--username", "wrongusername"}, + expectedErr: "the user testuser who has email testuser@gitea.local does not match the provided username wrongusername", + }, + { + name: "user exists but provided id does not match", + args: []string{"delete", "--username", "testuser", "--id", "999"}, + expectedErr: "the user testuser does not match the provided id 999", + }, + { + name: "no required flags are provided", + args: []string{"delete"}, + expectedErr: "You must provide the id, username or email of a user to delete", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + if strings.Contains(tc.name, "user exists") { + unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "testuser"}) + err := microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}) + require.NoError(t, err) + } + + err := microcmdUserDelete().Run(ctx, tc.args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) + require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.AccessToken{})) + } +} diff --git a/cmd/admin_user_disable_2fa.go b/cmd/admin_user_disable_2fa.go new file mode 100644 index 0000000..b5b59e5 --- /dev/null +++ b/cmd/admin_user_disable_2fa.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + auth_model "gitea.dev/models/auth" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func microcmdUserDisableTwoFactor() *cli.Command { + return &cli.Command{ + Name: "disable-2fa", + Usage: "Disable two-factor authentication for a user", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username of the user to disable 2FA for", + }, + &cli.Int64Flag{ + Name: "id", + Usage: "ID of the user to disable 2FA for", + }, + }, + Action: runDisableTwoFactor, + } +} + +func runDisableTwoFactor(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") && !c.IsSet("username") { + return errors.New("either --id or --username must be provided") + } + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + var user *user_model.User + var err error + if c.IsSet("id") { + user, err = user_model.GetUserByID(ctx, c.Int64("id")) + } else { + user, err = user_model.GetUserByName(ctx, c.String("username")) + } + if err != nil { + return err + } + + // When both selectors are given, make sure they refer to the same user. + if c.IsSet("id") && c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) { + return fmt.Errorf("the user with id %d is %q, which does not match the provided username %q", user.ID, user.Name, c.String("username")) + } + + totp, webAuthn, err := auth_model.DisableTwoFactor(ctx, user.ID) + if err != nil { + return err + } + + cprintf(c, "Disabled 2FA for user %q (removed %d TOTP and %d WebAuthn credential(s))\n", user.Name, totp, webAuthn) + return nil +} diff --git a/cmd/admin_user_disable_2fa_test.go b/cmd/admin_user_disable_2fa_test.go new file mode 100644 index 0000000..4fe9497 --- /dev/null +++ b/cmd/admin_user_disable_2fa_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "strconv" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDisableTwoFactorCommand(t *testing.T) { + ctx := t.Context() + + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{}, &auth_model.TwoFactor{}, &auth_model.WebAuthnCredential{})) + }() + + t.Run("disable TOTP and WebAuthn", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "tfuser", "--email", "tfuser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "tfuser"}) + + // Enroll TOTP. + tf := &auth_model.TwoFactor{UID: user.ID} + require.NoError(t, tf.SetSecret("test-secret")) + _, err := tf.GenerateScratchToken() + require.NoError(t, err) + require.NoError(t, auth_model.NewTwoFactor(ctx, tf)) + + // Register a WebAuthn credential. + _, err = auth_model.CreateCredential(ctx, user.ID, "test-key", &webauthn.Credential{ID: []byte("test-cred-id")}) + require.NoError(t, err) + + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + require.True(t, has) + + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--username", "tfuser"})) + + // Both factors must be gone afterwards. + has, err = auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + assert.False(t, has) + }) + + t.Run("disable by id", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "iduser", "--email", "iduser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "iduser"}) + + tf := &auth_model.TwoFactor{UID: user.ID} + require.NoError(t, tf.SetSecret("test-secret")) + require.NoError(t, auth_model.NewTwoFactor(ctx, tf)) + + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--id", strconv.FormatInt(user.ID, 10)})) + + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + assert.False(t, has) + }) + + t.Run("no enrollment is a no-op", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "plainuser", "--email", "plainuser@gitea.local", "--random-password"})) + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--username", "plainuser"})) + }) + + t.Run("id and username must match when both given", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "matchuser", "--email", "matchuser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "matchuser"}) + id := strconv.FormatInt(user.ID, 10) + + // Matching id + username is accepted. + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--id", id, "--username", "matchuser"})) + + // Mismatched id + username is rejected. + cmd := microcmdUserDisableTwoFactor() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, []string{"disable-2fa", "--id", id, "--username", "someotheruser"}) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match the provided username") + }) + + t.Run("failure cases", func(t *testing.T) { + testCases := []struct { + name string + args []string + expectedErr string + }{ + { + name: "user does not exist", + args: []string{"disable-2fa", "--username", "nonexistentuser"}, + expectedErr: "user does not exist", + }, + { + name: "neither id nor username", + args: []string{"disable-2fa"}, + expectedErr: "either --id or --username must be provided", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := microcmdUserDisableTwoFactor() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, tc.args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + } + }) +} diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go new file mode 100644 index 0000000..e1b0e98 --- /dev/null +++ b/cmd/admin_user_generate_access_token.go @@ -0,0 +1,97 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + + auth_model "gitea.dev/models/auth" + user_model "gitea.dev/models/user" + + "github.com/urfave/cli/v3" +) + +func newUserGenerateAccessTokenCommand() *cli.Command { + return &cli.Command{ + Name: "generate-access-token", + Usage: "Generate an access token for a specific user", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username", + }, + &cli.StringFlag{ + Name: "token-name", + Aliases: []string{"t"}, + Usage: "Token name", + Value: "gitea-admin", + }, + &cli.BoolFlag{ + Name: "raw", + Usage: "Display only the token value", + }, + &cli.StringFlag{ + Name: "scopes", + Value: "all", + Usage: `Comma separated list of scopes to apply to access token, examples: "all", "public-only,read:issue", "write:repository,write:user"`, + }, + }, + Action: runGenerateAccessToken, + } +} + +func runGenerateAccessToken(ctx context.Context, c *cli.Command) error { + if !c.IsSet("username") { + return errors.New("you must provide a username to generate a token for") + } + + if err := initDB(ctx); err != nil { + return err + } + + user, err := user_model.GetUserByName(ctx, c.String("username")) + if err != nil { + return err + } + + // construct token with name and user so we can make sure it is unique + t := &auth_model.AccessToken{ + Name: c.String("token-name"), + UID: user.ID, + } + + exist, err := auth_model.AccessTokenByNameExists(ctx, t) + if err != nil { + return err + } + if exist { + return errors.New("access token name has been used already") + } + + // make sure the scopes are valid + accessTokenScope, err := auth_model.AccessTokenScope(c.String("scopes")).Normalize() + if err != nil { + return fmt.Errorf("invalid access token scope provided: %w", err) + } + if !accessTokenScope.HasPermissionScope() { + return errors.New("access token does not have any permission") + } + t.Scope = accessTokenScope + + // create the token + if err := auth_model.NewAccessToken(ctx, t); err != nil { + return err + } + + if c.Bool("raw") { + cprintln(c, t.Token) + } else { + cprintf(c, "Access token was successfully created: %s\n", t.Token) + } + + return nil +} diff --git a/cmd/admin_user_list.go b/cmd/admin_user_list.go new file mode 100644 index 0000000..7f9ce57 --- /dev/null +++ b/cmd/admin_user_list.go @@ -0,0 +1,60 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + user_model "gitea.dev/models/user" + + "github.com/urfave/cli/v3" +) + +func newUserListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List users", + Action: runListUsers, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "admin", + Usage: "List only admin users", + }, + }, + } +} + +func runListUsers(ctx context.Context, c *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + users, err := user_model.GetAllUsers(ctx) + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0) + + if c.IsSet("admin") { + fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n") + for _, u := range users { + if u.IsAdmin { + fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive) + } + } + } else { + twofa := user_model.UserList(users).GetTwoFaStatus(ctx) + fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n") + for _, u := range users { + fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID]) + } + } + + w.Flush() + return nil +} diff --git a/cmd/admin_user_must_change_password.go b/cmd/admin_user_must_change_password.go new file mode 100644 index 0000000..185b9e6 --- /dev/null +++ b/cmd/admin_user_must_change_password.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func microcmdUserMustChangePassword() *cli.Command { + return &cli.Command{ + Name: "must-change-password", + Usage: "Set the must change password flag for the provided users or all users", + Action: runMustChangePassword, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"A"}, + Usage: "All users must change password, except those explicitly excluded with --exclude", + }, + &cli.StringSliceFlag{ + Name: "exclude", + Aliases: []string{"e"}, + Usage: "Do not change the must-change-password flag for these users", + }, + &cli.BoolFlag{ + Name: "unset", + Usage: "Instead of setting the must-change-password flag, unset it", + }, + }, + } +} + +func runMustChangePassword(ctx context.Context, c *cli.Command) error { + if c.NArg() == 0 && !c.IsSet("all") { + return errors.New("either usernames or --all must be provided") + } + + mustChangePassword := !c.Bool("unset") + all := c.Bool("all") + exclude := c.StringSlice("exclude") + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + n, err := user_model.SetMustChangePassword(ctx, all, mustChangePassword, c.Args().Slice(), exclude) + if err != nil { + return err + } + + // codeql[disable-next-line=go/clear-text-logging] + cprintf(c, "Updated %d users setting MustChangePassword to %t\n", n, mustChangePassword) + return nil +} diff --git a/cmd/admin_user_must_change_password_test.go b/cmd/admin_user_must_change_password_test.go new file mode 100644 index 0000000..cf81fbe --- /dev/null +++ b/cmd/admin_user_must_change_password_test.go @@ -0,0 +1,78 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMustChangePassword(t *testing.T) { + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + }() + err := microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}) + require.NoError(t, err) + err = microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuserexclude", "--email", "testuserexclude@gitea.local", "--random-password"}) + require.NoError(t, err) + // Reset password change flag + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--all", "--unset"}) + require.NoError(t, err) + + testUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.False(t, testUser.MustChangePassword) + testUserExclude := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.False(t, testUserExclude.MustChangePassword) + + // Make all users change password + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--all"}) + require.NoError(t, err) + + testUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, testUser.MustChangePassword) + testUserExclude = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.True(t, testUserExclude.MustChangePassword) + + // Reset password change flag but exclude all tested users + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--all", "--unset", "--exclude", "testuser,testuserexclude"}) + require.NoError(t, err) + + testUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, testUser.MustChangePassword) + testUserExclude = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.True(t, testUserExclude.MustChangePassword) + + // Reset password change flag by listing multiple users + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--unset", "testuser", "testuserexclude"}) + require.NoError(t, err) + + testUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.False(t, testUser.MustChangePassword) + testUserExclude = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.False(t, testUserExclude.MustChangePassword) + + // Exclude a user from all user + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--all", "--exclude", "testuserexclude"}) + require.NoError(t, err) + + testUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, testUser.MustChangePassword) + testUserExclude = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.False(t, testUserExclude.MustChangePassword) + + // Unset a flag for single user + err = microcmdUserMustChangePassword().Run(t.Context(), []string{"change-test", "--unset", "testuser"}) + require.NoError(t, err) + + testUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.False(t, testUser.MustChangePassword) + testUserExclude = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuserexclude"}) + assert.False(t, testUserExclude.MustChangePassword) +} diff --git a/cmd/audit.go b/cmd/audit.go new file mode 100644 index 0000000..7ccad81 --- /dev/null +++ b/cmd/audit.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + audit_model "gitea.dev/models/audit" + user_model "gitea.dev/models/user" + "gitea.dev/services/audit" +) + +func cliAuditContext(ctx context.Context) context.Context { + ctx = audit.WithOrigin(ctx, audit_model.OriginCLI) + return audit.WithDoer(ctx, user_model.NewCliUser()) +} diff --git a/cmd/cert.go b/cmd/cert.go new file mode 100644 index 0000000..53b4f9d --- /dev/null +++ b/cmd/cert.go @@ -0,0 +1,206 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "os" + "strings" + "time" + + "github.com/urfave/cli/v3" +) + +// cmdCert represents the available cert sub-command. +func cmdCert() *cli.Command { + return &cli.Command{ + Name: "cert", + Usage: "Generate self-signed certificate", + Description: `Generate a self-signed X.509 certificate for a TLS server. +Outputs to 'cert.pem' and 'key.pem' and will overwrite existing files.`, + Action: runCert, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "host", + Usage: "Comma-separated hostnames and IPs to generate a certificate for", + Required: true, + }, + &cli.StringFlag{ + Name: "ecdsa-curve", + Value: "", + Usage: "ECDSA curve to use to generate a key. Valid values are P224, P256, P384, P521", + }, + &cli.IntFlag{ + Name: "rsa-bits", + Value: 3072, + Usage: "Size of RSA key to generate. Ignored if --ecdsa-curve is set", + }, + &cli.StringFlag{ + Name: "start-date", + Value: "", + Usage: "Creation date formatted as Jan 1 15:04:05 2011", + }, + &cli.DurationFlag{ + Name: "duration", + Value: 365 * 24 * time.Hour, + Usage: "Duration that certificate is valid for", + }, + &cli.BoolFlag{ + Name: "ca", + Usage: "whether this cert should be its own Certificate Authority", + }, + &cli.StringFlag{ + Name: "out", + Value: "cert.pem", + Usage: "Path to the file where there certificate will be saved", + }, + &cli.StringFlag{ + Name: "keyout", + Value: "key.pem", + Usage: "Path to the file where there certificate key will be saved", + }, + }, + } +} + +func publicKey(priv any) any { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func pemBlockForKey(priv any) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + log.Fatalf("Unable to marshal ECDSA private key: %v", err) + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + return nil + } +} + +func runCert(_ context.Context, c *cli.Command) error { + var priv any + var err error + switch c.String("ecdsa-curve") { + case "": + priv, err = rsa.GenerateKey(rand.Reader, c.Int("rsa-bits")) + case "P224": + priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + case "P256": + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "P384": + priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "P521": + priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + err = fmt.Errorf("unrecognized elliptic curve: %q", c.String("ecdsa-curve")) + } + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + var notBefore time.Time + if startDate := c.String("start-date"); startDate != "" { + notBefore, err = time.Parse("Jan 2 15:04:05 2006", startDate) + if err != nil { + return fmt.Errorf("failed to parse creation date %w", err) + } + } else { + notBefore = time.Now() + } + + notAfter := notBefore.Add(c.Duration("duration")) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + CommonName: "Gitea", + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + hosts := strings.SplitSeq(c.String("host"), ",") + for h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + if c.Bool("ca") { + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + certOut, err := os.Create(c.String("out")) + if err != nil { + return fmt.Errorf("failed to open %s for writing: %w", c.String("keyout"), err) + } + err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if err != nil { + return fmt.Errorf("failed to encode certificate: %w", err) + } + err = certOut.Close() + if err != nil { + return fmt.Errorf("failed to write cert: %w", err) + } + fmt.Fprintf(c.Writer, "Written cert to %s\n", c.String("out")) + + keyOut, err := os.OpenFile(c.String("keyout"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("failed to open %s for writing: %w", c.String("keyout"), err) + } + err = pem.Encode(keyOut, pemBlockForKey(priv)) + if err != nil { + return fmt.Errorf("failed to encode key: %w", err) + } + err = keyOut.Close() + if err != nil { + return fmt.Errorf("failed to write key: %w", err) + } + fmt.Fprintf(c.Writer, "Written key to %s\n", c.String("keyout")) + return nil +} diff --git a/cmd/cert_test.go b/cmd/cert_test.go new file mode 100644 index 0000000..c5775e5 --- /dev/null +++ b/cmd/cert_test.go @@ -0,0 +1,125 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCertCommand(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "RSA cert generation", + args: []string{ + "cert-test", + "--host", "localhost", + "--rsa-bits", "2048", + "--duration", "1h", + "--start-date", "Jan 1 00:00:00 2024", + }, + }, + { + name: "ECDSA cert generation", + args: []string{ + "cert-test", + "--host", "localhost", + "--ecdsa-curve", "P256", + "--duration", "1h", + "--start-date", "Jan 1 00:00:00 2024", + }, + }, + { + name: "mixed host, certificate authority", + args: []string{ + "cert-test", + "--host", "localhost,127.0.0.1", + "--duration", "1h", + "--start-date", "Jan 1 00:00:00 2024", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + app := cmdCert() + tempDir := t.TempDir() + + certFile := filepath.Join(tempDir, "cert.pem") + keyFile := filepath.Join(tempDir, "key.pem") + + err := app.Run(t.Context(), append(c.args, "--out", certFile, "--keyout", keyFile)) + require.NoError(t, err) + + assert.FileExists(t, certFile) + assert.FileExists(t, keyFile) + }) + } +} + +func TestCertCommandFailures(t *testing.T) { + cases := []struct { + name string + args []string + errMsg string + }{ + { + name: "Start Date Parsing failure", + args: []string{ + "cert-test", + "--host", "localhost", + "--start-date", "invalid-date", + }, + errMsg: "parsing time", + }, + { + name: "Unknown curve", + args: []string{ + "cert-test", + "--host", "localhost", + "--ecdsa-curve", "invalid-curve", + }, + errMsg: "unrecognized elliptic curve", + }, + { + name: "Key generation failure", + args: []string{ + "cert-test", + "--host", "localhost", + "--rsa-bits", "invalid-bits", + }, + }, + { + name: "Missing parameters", + args: []string{ + "cert-test", + }, + errMsg: `"host" not set`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + app := cmdCert() + app.Writer, app.ErrWriter = io.Discard, io.Discard + tempDir := t.TempDir() + + certFile := filepath.Join(tempDir, "cert.pem") + keyFile := filepath.Join(tempDir, "key.pem") + err := app.Run(t.Context(), append(c.args, "--out", certFile, "--keyout", keyFile)) + require.Error(t, err) + if c.errMsg != "" { + assert.ErrorContains(t, err, c.errMsg) + } + assert.NoFileExists(t, certFile) + assert.NoFileExists(t, keyFile) + }) + } +} diff --git a/cmd/cmdtest/cmd_test.go b/cmd/cmdtest/cmd_test.go new file mode 100644 index 0000000..f41793c --- /dev/null +++ b/cmd/cmdtest/cmd_test.go @@ -0,0 +1,248 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Tests here reload the config system multiple times with uncontrollable details. +// So they must be in a separate package, to avoid affecting other tests + +package cmdtest + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "testing" + + "gitea.dev/cmd" + "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + "gitea.dev/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func makePathOutput(workPath, customPath, customConf string) string { + return fmt.Sprintf("WorkPath=%s\nCustomPath=%s\nCustomConf=%s", workPath, customPath, customConf) +} + +func newTestApp(testCmd cli.Command) *cli.Command { + app := cmd.NewMainApp(cmd.AppVersion{}) + testCmd.Name = util.IfZero(testCmd.Name, "test-cmd") + cmd.PrepareSubcommandWithGlobalFlags(&testCmd) + app.Commands = append(app.Commands, &testCmd) + app.DefaultCommand = testCmd.Name + return app +} + +type runResult struct { + Stdout string + Stderr string + ExitCode int +} + +func runTestApp(app *cli.Command, args ...string) (runResult, error) { + outBuf := new(strings.Builder) + errBuf := new(strings.Builder) + app.Writer = outBuf + app.ErrWriter = errBuf + exitCode := -1 + defer test.MockVariableValue(&cli.ErrWriter, app.ErrWriter)() + defer test.MockVariableValue(&cli.OsExiter, func(code int) { + if exitCode == -1 { + exitCode = code // save the exit code once and then reset the writer (to simulate the exit) + app.Writer, app.ErrWriter, cli.ErrWriter = io.Discard, io.Discard, io.Discard + } + })() + err := cmd.RunMainApp(app, args...) + return runResult{outBuf.String(), errBuf.String(), exitCode}, err +} + +func TestCliCmd(t *testing.T) { + defaultWorkPath := filepath.FromSlash("/tmp/mocked-work-path") + defaultCustomPath := filepath.Join(defaultWorkPath, "custom") + defaultCustomConf := filepath.Join(defaultCustomPath, "conf/app.ini") + defer setting.MockBuiltinPaths(defaultWorkPath, "", "")() + + cli.CommandHelpTemplate = "(command help template)" + cli.RootCommandHelpTemplate = "(app help template)" + cli.SubcommandHelpTemplate = "(subcommand help template)" + + cases := []struct { + env map[string]string + cmd string + exp string + }{ + // help commands + { + cmd: "./gitea -h", + exp: "DEFAULT CONFIGURATION:", + }, + { + cmd: "./gitea help", + exp: "DEFAULT CONFIGURATION:", + }, + + { + cmd: "./gitea -c /dev/null -h", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea help -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null test-cmd -h", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -c /dev/null -h", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -h -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null test-cmd help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -c /dev/null help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd help -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + // parse paths + { + cmd: "./gitea test-cmd", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, defaultCustomConf), + }, + { + cmd: "./gitea -c /tmp/app.ini test-cmd", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), + }, + { + cmd: "./gitea test-cmd -c /tmp/app.ini", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd", + exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/custom/conf/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd --work-path /tmp/other", + exp: makePathOutput("/tmp/other", "/tmp/other/custom", "/tmp/other/custom/conf/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd --config /tmp/app-other.ini", + exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/app-other.ini"), + }, + } + + for _, c := range cases { + t.Run(c.cmd, func(t *testing.T) { + app := newTestApp(cli.Command{ + Action: func(ctx context.Context, cmd *cli.Command) error { + _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) + return nil + }, + }) + for k, v := range c.env { + t.Setenv(k, v) + } + args := strings.Split(c.cmd, " ") // for test only, "split" is good enough + r, err := runTestApp(app, args...) + assert.NoError(t, err, c.cmd) + assert.NotEmpty(t, c.exp, c.cmd) + if !assert.Contains(t, r.Stdout, c.exp, c.cmd) { + t.Log("Full output:\n" + r.Stdout) + t.Log("Expected:\n" + c.exp) + } + }) + } +} + +func TestCliCmdError(t *testing.T) { + app := newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }}) + r, err := runTestApp(app, "./gitea", "test-cmd") + assert.Error(t, err) + assert.Equal(t, 1, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "Command error: normal error\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }}) + r, err = runTestApp(app, "./gitea", "test-cmd") + assert.Error(t, err) + assert.Equal(t, 2, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "exit error\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) + r, err = runTestApp(app, "./gitea", "test-cmd", "--no-such") + assert.Error(t, err) + assert.Equal(t, 1, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) + r, err = runTestApp(app, "./gitea", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, -1, r.ExitCode) // the cli.OsExiter is not called + assert.Empty(t, r.Stdout) + assert.Empty(t, r.Stderr) +} + +func TestCliCmdBefore(t *testing.T) { + ctxNew := context.WithValue(context.Background(), any("key"), "value") + configValues := map[string]string{} + setting.CustomConf = "/tmp/any.ini" + var actionCtx context.Context + app := newTestApp(cli.Command{ + Before: func(context.Context, *cli.Command) (context.Context, error) { + configValues["before"] = setting.CustomConf + return ctxNew, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + configValues["action"] = setting.CustomConf + actionCtx = ctx + return nil + }, + }) + _, err := runTestApp(app, "./gitea", "--config", "/dev/null", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, ctxNew, actionCtx) + assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config") + assert.Equal(t, "/dev/null", configValues["action"]) +} + +func TestCliCmdCompletion(t *testing.T) { + app := newTestApp(cli.Command{ + Action: func(ctx context.Context, cmd *cli.Command) error { return nil }, + }) + res, err := runTestApp(app, "./gitea", "completion", "bash", "--nonexist") + assert.Error(t, err) + assert.Equal(t, 1, res.ExitCode) + assert.Equal(t, "", res.Stdout) + assert.Equal(t, "Incorrect Usage: flag provided but not defined: -nonexist\n", res.Stderr) +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..42880d9 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func cmdConfig() *cli.Command { + subcmdConfigEditIni := &cli.Command{ + Name: "edit-ini", + Usage: "Load an existing INI file, apply environment variables, keep specified keys, and output to a new INI file.", + Description: ` +Help users to edit the Gitea configuration INI file. + +# Keep Specified Keys + +If you need to re-create the configuration file with only a subset of keys, +you can provide an INI template file for the kept keys and use the "--config-keep-keys" flag. +For example, if a helm chart needs to reset the settings and only keep SECRET_KEY, +it can use a template file (only keys take effect, values are ignored): + + [security] + SECRET_KEY= + +$ ./gitea config edit-ini --config app-old.ini --config-keep-keys app-keys.ini --out app-new.ini + +# Map Environment Variables to INI Configuration + +Environment variables of the form "GITEA__section_name__KEY_NAME" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value as provided. + +Environment variables of the form "GITEA__section_name__KEY_NAME__FILE" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value loaded from the specified file. + +Environment variable keys can only contain characters "0-9A-Z_", +if a section or key name contains dot ".", it needs to be escaped as _0x2E_. +For example, to apply this config: + + [git.config] + foo.bar=val + +$ export GITEA__git_0x2E_config__foo_0x2E_bar=val + +# Put All Together + +$ ./gitea config edit-ini --config app.ini --config-keep-keys app-keys.ini --apply-env {--in-place|--out app-new.ini} +`, + Flags: []cli.Flag{ + // "--config" flag is provided by global flags, and this flag is also used by "environment-to-ini" script wrapper + // "--in-place" is also used by "environment-to-ini" script wrapper for its old behavior: always overwrite the existing config file + &cli.BoolFlag{ + Name: "in-place", + Usage: "Output to the same config file as input. This flag will be ignored if --out is set.", + }, + &cli.StringFlag{ + Name: "config-keep-keys", + Usage: "An INI template file containing keys for keeping. Only the keys defined in the INI template will be kept from old config. If not set, all keys will be kept.", + }, + &cli.BoolFlag{ + Name: "apply-env", + Usage: "Apply all GITEA__* variables from the environment to the config.", + }, + &cli.StringFlag{ + Name: "out", + Usage: "Destination config file to write to.", + }, + }, + Action: runConfigEditIni, + } + + return &cli.Command{ + Name: "config", + Usage: "Manage Gitea configuration", + Commands: []*cli.Command{ + subcmdConfigEditIni, + }, + } +} + +func runConfigEditIni(_ context.Context, c *cli.Command) error { + // the config system may change the environment variables, so get a copy first, to be used later + env := append([]string{}, os.Environ()...) + + // don't use the guessed setting.CustomConf, instead, require the user to provide --config explicitly + if !c.IsSet("config") { + return errors.New("flag is required but not set: --config") + } + configFileIn := c.String("config") + + cfgIn, err := setting.NewConfigProviderFromFile(configFileIn) + if err != nil { + return fmt.Errorf("failed to load config file %q: %v", configFileIn, err) + } + + // determine output config file: use "--out" flag or use "--in-place" flag to overwrite input file + inPlace := c.Bool("in-place") + configFileOut := c.String("out") + if configFileOut == "" { + if !inPlace { + return errors.New("either --in-place or --out must be specified") + } + configFileOut = configFileIn // in-place edit + } + + needWriteOut := configFileOut != configFileIn + + cfgOut := cfgIn + configKeepKeys := c.String("config-keep-keys") + if configKeepKeys != "" { + needWriteOut = true + cfgOut, err = setting.NewConfigProviderFromFile(configKeepKeys) + if err != nil { + return fmt.Errorf("failed to load config-keep-keys template file %q: %v", configKeepKeys, err) + } + + for _, secOut := range cfgOut.Sections() { + for _, keyOut := range secOut.Keys() { + secIn := cfgIn.Section(secOut.Name()) + keyIn := setting.ConfigSectionKey(secIn, keyOut.Name()) + if keyIn != nil { + keyOut.SetValue(keyIn.String()) + } else { + secOut.DeleteKey(keyOut.Name()) + } + } + if len(secOut.Keys()) == 0 { + cfgOut.DeleteSection(secOut.Name()) + } + } + } + + if c.Bool("apply-env") { + if setting.EnvironmentToConfig(cfgOut, env) { + needWriteOut = true + } + } + + if needWriteOut { + err = cfgOut.SaveTo(configFileOut) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..d123daa --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,85 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigEdit(t *testing.T) { + tmpDir := t.TempDir() + configOld := tmpDir + "/app-old.ini" + configTemplate := tmpDir + "/app-template.ini" + _ = os.WriteFile(configOld, []byte(` +[sec] +k1=v1 +k2=v2 +`), os.ModePerm) + + _ = os.WriteFile(configTemplate, []byte(` +[sec] +k1=in-template + +[sec2] +k3=v3 +`), os.ModePerm) + + t.Setenv("GITEA__EnV__KeY", "val") + + t.Run("OutputToNewWithEnv", func(t *testing.T) { + configNew := tmpDir + "/app-new.ini" + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "--config", configOld, + "config", "edit-ini", + "--apply-env", + "--config-keep-keys", configTemplate, + "--out", configNew, + }) + require.NoError(t, err) + + // "k1" old value is kept because its key is in the template + // "k2" is removed because it isn't in the template + // "k3" isn't in new config because it isn't in the old config + // [env] is applied from environment variable + data, _ := os.ReadFile(configNew) + require.Equal(t, `[sec] +k1 = v1 + +[env] +KeY = val +`, string(data)) + }) + + t.Run("OutputToExisting(environment-to-ini)", func(t *testing.T) { + // the legacy "environment-to-ini" (now a wrapper script) behavior: + // if no "--out", then "--in-place" must be used to overwrite the existing "--config" file + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--apply-env", + "--config", configOld, + }) + require.ErrorContains(t, err, "either --in-place or --out must be specified") + + // simulate the "environment-to-ini" behavior with "--in-place" + err = NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--in-place", + "--apply-env", + "--config", configOld, + }) + require.NoError(t, err) + + data, _ := os.ReadFile(configOld) + require.Equal(t, `[sec] +k1 = v1 +k2 = v2 + +[env] +KeY = val +`, string(data)) + }) +} diff --git a/cmd/docs.go b/cmd/docs.go new file mode 100644 index 0000000..8e0f942 --- /dev/null +++ b/cmd/docs.go @@ -0,0 +1,68 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + cli_docs "github.com/urfave/cli-docs/v3" + "github.com/urfave/cli/v3" +) + +func newDocsCommand() *cli.Command { + return &cli.Command{ + Name: "docs", + Usage: "Output CLI documentation", + Description: "A command to output Gitea's CLI documentation, optionally to a file.", + Action: runDocs, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "man", + Usage: "Output man pages instead", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Path to output to instead of stdout (will overwrite if exists)", + }, + }, + } +} + +func runDocs(_ context.Context, cmd *cli.Command) error { + docs, err := cli_docs.ToMarkdown(cmd.Root()) + if cmd.Bool("man") { + docs, err = cli_docs.ToMan(cmd.Root()) + } + if err != nil { + return err + } + + if !cmd.Bool("man") { + // Clean up markdown. The following bug was fixed in v2, but is present in v1. + // It affects markdown output (even though the issue is referring to man pages) + // https://github.com/urfave/cli/issues/1040 + firstHashtagIndex := strings.Index(docs, "#") + + if firstHashtagIndex > 0 { + docs = docs[firstHashtagIndex:] + } + } + + out := os.Stdout + if cmd.String("output") != "" { + fi, err := os.Create(cmd.String("output")) + if err != nil { + return err + } + defer fi.Close() + out = fi + } + + _, err = fmt.Fprintln(out, docs) + return err +} diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..7c55c2d --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,218 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + golog "log" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + "gitea.dev/modelmigration" + "gitea.dev/modelmigration/base" + "gitea.dev/models/db" + "gitea.dev/modules/container" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + "gitea.dev/services/doctor" + + "github.com/urfave/cli/v3" +) + +func newDoctorCommand() *cli.Command { + return &cli.Command{ + Name: "doctor", + Usage: "Diagnose and optionally fix problems, convert or re-create database tables", + Description: "A command to diagnose problems with the current Gitea instance according to the given configuration. Some problems can optionally be fixed by modifying the database or data storage.", + Commands: []*cli.Command{ + newDoctorCheckCommand(), + newRecreateTableCommand(), + newDoctorConvertCommand(), + }, + } +} + +func newDoctorCheckCommand() *cli.Command { + return &cli.Command{ + Name: "check", + Usage: "Diagnose and optionally fix problems", + Description: "A command to diagnose problems with the current Gitea instance according to the given configuration. Some problems can optionally be fixed by modifying the database or data storage.", + Action: runDoctorCheck, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "list", + Usage: "List the available checks", + }, + &cli.BoolFlag{ + Name: "default", + Usage: "Run the default checks (if neither --run or --all is set, this is the default behaviour)", + }, + &cli.StringSliceFlag{ + Name: "run", + Usage: "Run the provided checks - (if --default is set, the default checks will also run)", + }, + &cli.BoolFlag{ + Name: "all", + Usage: "Run all the available checks", + }, + &cli.BoolFlag{ + Name: "fix", + Usage: "Automatically fix what we can", + }, + &cli.StringFlag{ + Name: "log-file", + Usage: `Name of the log file (no verbose log output by default). Set to "-" to output to stdout`, + }, + &cli.BoolFlag{ + Name: "color", + Aliases: []string{"H"}, + Usage: "Use color for outputted information", + }, + }, + } +} + +func newRecreateTableCommand() *cli.Command { + return &cli.Command{ + Name: "recreate-table", + Usage: "Recreate tables from XORM definitions and copy the data.", + ArgsUsage: "[TABLE]... : (TABLEs to recreate - leave blank for all)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "Print SQL commands sent", + }, + }, + Description: `The database definitions Gitea uses change across versions, sometimes changing default values and leaving old unused columns. + +This command will cause Xorm to recreate tables, copying over the data and deleting the old table. + +You should back-up your database before doing this and ensure that your database is up-to-date first.`, + Action: runRecreateTable, + } +} + +func runRecreateTable(ctx context.Context, cmd *cli.Command) error { + // Redirect the default golog to here + golog.SetFlags(0) + golog.SetPrefix("") + golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info)) + + debug := cmd.Bool("debug") + setting.MustInstalled() + setting.LoadDBSetting() + + if debug { + setting.InitSQLLoggersForCli(log.DEBUG) + } else { + setting.InitSQLLoggersForCli(log.INFO) + } + + setting.Database.LogSQL = debug + if err := db.InitEngine(ctx); err != nil { + cprintln(cmd, err) + cprintln(cmd, "Check if you are using the right config file. You can use a --config directive to specify one.") + return nil + } + + args := cmd.Args() + names := make([]string, 0, cmd.NArg()) + for i := 0; i < cmd.NArg(); i++ { + names = append(names, args.Get(i)) + } + + beans, err := db.NamesToBean(names...) + if err != nil { + return err + } + recreateTables := base.RecreateTables(beans...) + + return db.InitEngineWithMigration(context.Background(), func(ctx context.Context, x db.EngineMigration) error { + if err := modelmigration.EnsureUpToDate(ctx, x); err != nil { + return err + } + return recreateTables(x) + }) +} + +func setupDoctorDefaultLogger(cmd *cli.Command, colorize bool) { + // Silence the default loggers + setupConsoleLogger(log.FATAL, log.CanColorStderr, os.Stderr) + + logFile := cmd.String("log-file") + switch logFile { + case "": + return // if no doctor log-file is set, do not show any log from default logger + case "-": + setupConsoleLogger(log.TRACE, colorize, os.Stdout) + default: + logFile, _ = filepath.Abs(logFile) + writeMode := log.WriterMode{Level: log.TRACE, WriterOption: log.WriterFileOption{FileName: logFile}} + writer, err := log.NewEventWriter("console-to-file", "file", writeMode) + if err != nil { + log.FallbackErrorf("unable to create file log writer: %v", err) + return + } + log.GetManager().GetLogger(log.DEFAULT).ReplaceAllWriters(writer) + } +} + +func runDoctorCheck(ctx context.Context, cmd *cli.Command) error { + colorize := log.CanColorStdout + if cmd.IsSet("color") { + colorize = cmd.Bool("color") + } + + setupDoctorDefaultLogger(cmd, colorize) + + // Finally redirect the default golang's log to here + golog.SetFlags(0) + golog.SetPrefix("") + golog.SetOutput(log.LoggerToWriter(log.GetLogger(log.DEFAULT).Info)) + + if cmd.IsSet("list") { + w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + _, _ = w.Write([]byte("Default\tName\tTitle\n")) + doctor.SortChecks(doctor.Checks) + for _, check := range doctor.Checks { + if check.IsDefault { + _, _ = w.Write([]byte{'*'}) + } + _, _ = w.Write([]byte{'\t'}) + _, _ = w.Write([]byte(check.Name)) + _, _ = w.Write([]byte{'\t'}) + _, _ = w.Write([]byte(check.Title)) + _, _ = w.Write([]byte{'\n'}) + } + return w.Flush() + } + + var checks []*doctor.Check + if cmd.Bool("all") { + checks = make([]*doctor.Check, len(doctor.Checks)) + copy(checks, doctor.Checks) + } else if cmd.IsSet("run") { + addDefault := cmd.Bool("default") + runNamesSet := container.SetOf(cmd.StringSlice("run")...) + for _, check := range doctor.Checks { + if (addDefault && check.IsDefault) || runNamesSet.Contains(check.Name) { + checks = append(checks, check) + runNamesSet.Remove(check.Name) + } + } + if len(runNamesSet) > 0 { + return fmt.Errorf("unknown checks: %q", strings.Join(runNamesSet.Values(), ",")) + } + } else { + for _, check := range doctor.Checks { + if check.IsDefault { + checks = append(checks, check) + } + } + } + return doctor.RunChecks(ctx, colorize, cmd.Bool("fix"), checks) +} diff --git a/cmd/doctor_convert.go b/cmd/doctor_convert.go new file mode 100644 index 0000000..e712370 --- /dev/null +++ b/cmd/doctor_convert.go @@ -0,0 +1,54 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/models/db" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func newDoctorConvertCommand() *cli.Command { + return &cli.Command{ + Name: "convert", + Usage: "Convert the database", + Description: "A command to convert an existing MySQL database from utf8 to utf8mb4 or MSSQL database from varchar to nvarchar", + Action: runDoctorConvert, + } +} + +func runDoctorConvert(ctx context.Context, cmd *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + log.Info("AppPath: %s", setting.AppPath) + log.Info("AppWorkPath: %s", setting.AppWorkPath) + log.Info("Custom path: %s", setting.CustomPath) + log.Info("Log path: %s", setting.Log.RootPath) + log.Info("Configuration file: %s", setting.CustomConf) + + switch { + case setting.Database.Type.IsMySQL(): + if err := db.ConvertDatabaseTable(); err != nil { + log.Fatal("Failed to convert database & table: %v", err) + return err + } + cprintln(cmd, "Converted successfully, please confirm your database's character set is now utf8mb4") + case setting.Database.Type.IsMSSQL(): + if err := db.ConvertVarcharToNVarchar(); err != nil { + log.Fatal("Failed to convert database from varchar to nvarchar: %v", err) + return err + } + cprintln(cmd, "Converted successfully, please confirm your database's all columns character is NVARCHAR now") + default: + cprintln(cmd, "This command can only be used with a MySQL or MSSQL database") + } + + return nil +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..6cc22b0 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,34 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "gitea.dev/modules/log" + "gitea.dev/services/doctor" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestDoctorRun(t *testing.T) { + doctor.Register(&doctor.Check{ + Title: "Test Check", + Name: "test-check", + Run: func(ctx context.Context, logger log.Logger, autofix bool) error { return nil }, + + SkipDatabaseInitialization: true, + }) + app := &cli.Command{ + Commands: []*cli.Command{newDoctorCheckCommand()}, + } + err := app.Run(t.Context(), []string{"./gitea", "check", "--run", "test-check"}) + assert.NoError(t, err) + err = app.Run(t.Context(), []string{"./gitea", "check", "--run", "no-such"}) + assert.ErrorContains(t, err, `unknown checks: "no-such"`) + err = app.Run(t.Context(), []string{"./gitea", "check", "--run", "test-check,no-such"}) + assert.ErrorContains(t, err, `unknown checks: "no-such"`) +} diff --git a/cmd/dump.go b/cmd/dump.go new file mode 100644 index 0000000..037e788 --- /dev/null +++ b/cmd/dump.go @@ -0,0 +1,328 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "os" + "path" + "path/filepath" + "strings" + + "gitea.dev/models/db" + "gitea.dev/modules/dump" + "gitea.dev/modules/json" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + "gitea.dev/modules/storage" + "gitea.dev/modules/util" + + "gitea.com/go-chi/session" + "github.com/urfave/cli/v3" +) + +func newDumpCommand() *cli.Command { + return &cli.Command{ + Name: "dump", + Usage: "Dump Gitea files and database", + Description: `Dump compresses all related files and database into zip file. It can be used for backup and capture Gitea server image to send to maintainer`, + Action: runDump, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: `Name of the dump file which will be created, default to "gitea-dump-{time}.zip". Supply '-' for stdout. See type for available types.`, + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"V"}, + Usage: "Show process details", + }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display warnings and errors", + }, + &cli.StringFlag{ + Name: "tempdir", + Aliases: []string{"t"}, + Value: os.TempDir(), + Usage: "Temporary dir path", + }, + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Specify the database SQL syntax: sqlite3, mysql, mssql, postgres", + }, + &cli.BoolFlag{ + Name: "skip-repository", + Aliases: []string{"R"}, + Usage: "Skip the repository dumping", + }, + &cli.BoolFlag{ + Name: "skip-log", + Aliases: []string{"L"}, + Usage: "Skip the log dumping", + }, + &cli.BoolFlag{ + Name: "skip-custom-dir", + Usage: "Skip custom directory", + }, + &cli.BoolFlag{ + Name: "skip-lfs-data", + Usage: "Skip LFS data", + }, + &cli.BoolFlag{ + Name: "skip-attachment-data", + Usage: "Skip attachment data", + }, + &cli.BoolFlag{ + Name: "skip-package-data", + Usage: "Skip package data", + }, + &cli.BoolFlag{ + Name: "skip-index", + Usage: "Skip bleve index data", + }, + &cli.BoolFlag{ + Name: "skip-db", + Usage: "Skip database", + }, + &cli.StringFlag{ + Name: "type", + Usage: `Dump output format, default to "zip", supported types: ` + strings.Join(dump.SupportedOutputTypes, ", "), + }, + }, + } +} + +func fatal(format string, args ...any) { + log.Fatal(format, args...) +} + +func runDump(ctx context.Context, cmd *cli.Command) error { + setting.MustInstalled() + + quite := cmd.Bool("quiet") + verbose := cmd.Bool("verbose") + if verbose && quite { + fatal("Option --quiet and --verbose cannot both be set") + } + + // outFileName is either "-" or a file name (will be made absolute) + outFileName, outType := dump.PrepareFileNameAndType(cmd.String("file"), cmd.String("type")) + if outType == "" { + fatal("Invalid output type") + } + + outFile := os.Stdout + if outFileName != "-" { + var err error + if outFileName, err = filepath.Abs(outFileName); err != nil { + fatal("Unable to get absolute path of dump file: %v", err) + } + if exist, _ := util.IsExist(outFileName); exist { + fatal("Dump file %q exists", outFileName) + } + if outFile, err = os.Create(outFileName); err != nil { + fatal("Unable to create dump file %q: %v", outFileName, err) + } + defer outFile.Close() + } + + setupConsoleLogger(util.Iif(quite, log.WARN, log.INFO), log.CanColorStderr, os.Stderr) + + setting.DisableLoggerInit() + setting.LoadSettings() // cannot access session settings otherwise + + err := db.InitEngine(ctx) + if err != nil { + return err + } + + if err = storage.Init(); err != nil { + return err + } + + dumper, err := dump.NewDumper(ctx, outType, outFile) + if err != nil { + fatal("Failed to create archive %q: %v", outFile, err) + return err + } + dumper.Verbose = verbose + dumper.GlobalExcludeAbsPath(outFileName) + defer func() { + if err := dumper.Close(); err != nil { + fatal("Failed to save archive %q: %v", outFileName, err) + } + }() + + if cmd.IsSet("skip-repository") && cmd.Bool("skip-repository") { + log.Info("Skip dumping local repositories") + } else { + log.Info("Dumping local repositories... %s", setting.RepoRootPath) + if err := dumper.AddRecursiveExclude("repos", setting.RepoRootPath, nil); err != nil { + fatal("Failed to include repositories: %v", err) + } + + if cmd.IsSet("skip-lfs-data") && cmd.Bool("skip-lfs-data") { + log.Info("Skip dumping LFS data") + } else if !setting.LFS.StartServer { + log.Info("LFS isn't enabled. Skip dumping LFS data") + } else if err := storage.LFS.IterateObjects("", func(objPath string, object storage.Object) error { + info, err := object.Stat() + if err != nil { + return err + } + return dumper.AddFileByReader(object, info, path.Join("data", "lfs", objPath)) + }); err != nil { + fatal("Failed to dump LFS objects: %v", err) + } + } + + if cmd.Bool("skip-db") { + // Ensure that we don't dump the database file that may reside in setting.AppDataPath or elsewhere. + dumper.GlobalExcludeAbsPath(setting.Database.Path) + log.Info("Skipping database") + } else { + tmpDir := cmd.String("tempdir") + if _, err := os.Stat(tmpDir); os.IsNotExist(err) { + fatal("Path does not exist: %s", tmpDir) + } + + dbDump, err := os.CreateTemp(tmpDir, "gitea-db.sql") + if err != nil { + fatal("Failed to create tmp file: %v", err) + } + defer func() { + _ = dbDump.Close() + if err := os.Remove(dbDump.Name()); err != nil { + log.Warn("Unable to remove temporary file: %s: Error: %v", dbDump.Name(), err) + } + }() + + targetDBType := setting.DatabaseType(cmd.String("database")) + if targetDBType != "" && targetDBType != setting.Database.Type { + log.Info("Dumping database %s => %s...", setting.Database.Type, targetDBType) + } else { + log.Info("Dumping database...") + } + + if err := db.DumpDatabase(dbDump.Name(), targetDBType); err != nil { + fatal("Failed to dump database: %v", err) + } + + if err = dumper.AddFileByPath("gitea-db.sql", dbDump.Name()); err != nil { + fatal("Failed to include gitea-db.sql: %v", err) + } + } + + log.Info("Adding custom configuration file from %s", setting.CustomConf) + if err = dumper.AddFileByPath("app.ini", setting.CustomConf); err != nil { + fatal("Failed to include specified app.ini: %v", err) + } + + if cmd.IsSet("skip-custom-dir") && cmd.Bool("skip-custom-dir") { + log.Info("Skipping custom directory") + } else { + customDir, err := os.Stat(setting.CustomPath) + if err == nil && customDir.IsDir() { + if is, _ := dump.IsSubdir(setting.AppDataPath, setting.CustomPath); !is { + if err := dumper.AddRecursiveExclude("custom", setting.CustomPath, nil); err != nil { + fatal("Failed to include custom: %v", err) + } + } else { + log.Info("Custom dir %s is inside data dir %s, skipped", setting.CustomPath, setting.AppDataPath) + } + } else { + log.Info("Custom dir %s doesn't exist, skipped", setting.CustomPath) + } + } + + isExist, err := util.IsExist(setting.AppDataPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", setting.AppDataPath, err) + } + if isExist { + log.Info("Packing data directory...%s", setting.AppDataPath) + + var excludes []string + if setting.SessionConfig.OriginalProvider == "file" { + var opts session.Options + if err = json.Unmarshal([]byte(setting.SessionConfig.ProviderConfig), &opts); err != nil { + return err + } + excludes = append(excludes, opts.ProviderConfig) + } + + if cmd.IsSet("skip-index") && cmd.Bool("skip-index") { + excludes = append(excludes, setting.Indexer.RepoPath) + excludes = append(excludes, setting.Indexer.IssuePath) + } + + excludes = append(excludes, setting.RepoRootPath) + excludes = append(excludes, setting.LFS.Storage.Path) + excludes = append(excludes, setting.Attachment.Storage.Path) + excludes = append(excludes, setting.Packages.Storage.Path) + excludes = append(excludes, setting.RepoArchive.Storage.Path) + excludes = append(excludes, setting.Log.RootPath) + if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil { + fatal("Failed to include data directory: %v", err) + } + } + + if cmd.IsSet("skip-attachment-data") && cmd.Bool("skip-attachment-data") { + log.Info("Skip dumping attachment data") + } else if err := storage.Attachments.IterateObjects("", func(objPath string, object storage.Object) error { + info, err := object.Stat() + if err != nil { + return err + } + return dumper.AddFileByReader(object, info, path.Join("data", "attachments", objPath)) + }); err != nil { + fatal("Failed to dump attachments: %v", err) + } + + if cmd.IsSet("skip-package-data") && cmd.Bool("skip-package-data") { + log.Info("Skip dumping package data") + } else if !setting.Packages.Enabled { + log.Info("Packages isn't enabled. Skip dumping package data") + } else if err := storage.Packages.IterateObjects("", func(objPath string, object storage.Object) error { + info, err := object.Stat() + if err != nil { + return err + } + return dumper.AddFileByReader(object, info, path.Join("data", "packages", objPath)) + }); err != nil { + fatal("Failed to dump packages: %v", err) + } + + // Doesn't check if LogRootPath exists before processing --skip-log intentionally, + // ensuring that it's clear the dump is skipped whether the directory's initialized + // yet or not. + if cmd.IsSet("skip-log") && cmd.Bool("skip-log") { + log.Info("Skip dumping log files") + } else { + isExist, err := util.IsExist(setting.Log.RootPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err) + } + if isExist { + if err := dumper.AddRecursiveExclude("log", setting.Log.RootPath, nil); err != nil { + fatal("Failed to include log: %v", err) + } + } + } + + if outFileName == "-" { + log.Info("Finish dumping to stdout") + } else { + if err = os.Chmod(outFileName, 0o600); err != nil { + log.Info("Can't change file access permissions mask to 0600: %v", err) + } + log.Info("Finish dumping in file %s", outFileName) + } + return nil +} diff --git a/cmd/dump_repo.go b/cmd/dump_repo.go new file mode 100644 index 0000000..cc84922 --- /dev/null +++ b/cmd/dump_repo.go @@ -0,0 +1,195 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "gitea.dev/modules/git" + "gitea.dev/modules/log" + base "gitea.dev/modules/migration" + "gitea.dev/modules/setting" + "gitea.dev/modules/structs" + "gitea.dev/modules/util" + "gitea.dev/services/convert" + "gitea.dev/services/migrations" + + "github.com/urfave/cli/v3" +) + +func newDumpRepositoryCommand() *cli.Command { + return &cli.Command{ + Name: "dump-repo", + Usage: "Dump the repository from git/github/gitea/gitlab", + Description: "This is a command for dumping the repository data.", + Action: runDumpRepository, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "git_service", + Value: "", + Usage: "Git service, git, github, gitea, gitlab. If clone_addr could be recognized, this could be ignored.", + }, + &cli.StringFlag{ + Name: "repo_dir", + Aliases: []string{"r"}, + Value: "./data", + Usage: "Repository dir path to store the data", + }, + &cli.StringFlag{ + Name: "clone_addr", + Value: "", + Usage: "The URL will be clone, currently could be a git/github/gitea/gitlab http/https URL", + }, + &cli.StringFlag{ + Name: "auth_username", + Value: "", + Usage: "The username to visit the clone_addr", + }, + &cli.StringFlag{ + Name: "auth_password", + Value: "", + Usage: "The password to visit the clone_addr", + }, + &cli.StringFlag{ + Name: "auth_token", + Value: "", + Usage: "The personal token to visit the clone_addr", + }, + &cli.StringFlag{ + Name: "owner_name", + Value: "", + Usage: "The data will be stored on a directory with owner name if not empty", + }, + &cli.StringFlag{ + Name: "repo_name", + Value: "", + Usage: "The data will be stored on a directory with repository name if not empty", + }, + &cli.StringFlag{ + Name: "units", + Value: "", + Usage: `Which items will be migrated, one or more units should be separated as comma. +wiki, issues, labels, releases, release_assets, milestones, pull_requests, comments are allowed. Empty means all units.`, + }, + }, + } +} + +func runDumpRepository(ctx context.Context, cmd *cli.Command) error { + setupConsoleLogger(log.INFO, log.CanColorStderr, os.Stderr) + + setting.DisableLoggerInit() + setting.LoadSettings() // cannot access skip_tls_verify settings otherwise + + if err := initDB(ctx); err != nil { + return err + } + + // migrations.GiteaLocalUploader depends on git module + if err := git.InitSimple(); err != nil { + return err + } + + log.Info("AppPath: %s", setting.AppPath) + log.Info("AppWorkPath: %s", setting.AppWorkPath) + log.Info("Custom path: %s", setting.CustomPath) + log.Info("Log path: %s", setting.Log.RootPath) + log.Info("Configuration file: %s", setting.CustomConf) + + var ( + serviceType structs.GitServiceType + cloneAddr = cmd.String("clone_addr") + serviceStr = cmd.String("git_service") + ) + + if strings.HasPrefix(strings.ToLower(cloneAddr), "https://github.com/") { + serviceStr = "github" + } else if strings.HasPrefix(strings.ToLower(cloneAddr), "https://gitlab.com/") { + serviceStr = "gitlab" + } else if strings.HasPrefix(strings.ToLower(cloneAddr), "https://gitea.com/") { + serviceStr = "gitea" + } + if serviceStr == "" { + return errors.New("git_service missed or clone_addr cannot be recognized") + } + serviceType = convert.ToGitServiceType(serviceStr) + + opts := base.MigrateOptions{ + GitServiceType: serviceType, + CloneAddr: cloneAddr, + AuthUsername: cmd.String("auth_username"), + AuthPassword: cmd.String("auth_password"), + AuthToken: cmd.String("auth_token"), + RepoName: cmd.String("repo_name"), + } + + if len(cmd.String("units")) == 0 { + opts.Wiki = true + opts.Issues = true + opts.Milestones = true + opts.Labels = true + opts.Releases = true + opts.Comments = true + opts.PullRequests = true + opts.ReleaseAssets = true + } else { + units := strings.SplitSeq(cmd.String("units"), ",") + for unit := range units { + switch strings.ToLower(strings.TrimSpace(unit)) { + case "": + continue + case "wiki": + opts.Wiki = true + case "issues": + opts.Issues = true + case "milestones": + opts.Milestones = true + case "labels": + opts.Labels = true + case "releases": + opts.Releases = true + case "release_assets": + opts.ReleaseAssets = true + case "comments": + opts.Comments = true + case "pull_requests": + opts.PullRequests = true + default: + return errors.New("invalid unit: " + unit) + } + } + } + + // the repo_dir will be removed if error occurs in DumpRepository + // make sure the directory doesn't exist or is empty, prevent from deleting user files + repoDir := cmd.String("repo_dir") + if exists, err := util.IsExist(repoDir); err != nil { + return fmt.Errorf("unable to stat repo_dir %q: %w", repoDir, err) + } else if exists { + if isDir, _ := util.IsDir(repoDir); !isDir { + return fmt.Errorf("repo_dir %q already exists but it's not a directory", repoDir) + } + if dir, _ := os.ReadDir(repoDir); len(dir) > 0 { + return fmt.Errorf("repo_dir %q is not empty", repoDir) + } + } + + if err := migrations.DumpRepository( + ctx, + repoDir, + cmd.String("owner_name"), + opts, + ); err != nil { + log.Fatal("Failed to dump repository: %v", err) + return err + } + + log.Trace("Dump finished!!!") + + return nil +} diff --git a/cmd/embedded.go b/cmd/embedded.go new file mode 100644 index 0000000..56d7e4c --- /dev/null +++ b/cmd/embedded.go @@ -0,0 +1,313 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.dev/modules/assetfs" + "gitea.dev/modules/glob" + "gitea.dev/modules/log" + "gitea.dev/modules/options" + "gitea.dev/modules/public" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + + "github.com/urfave/cli/v3" +) + +var matchedAssetFiles []assetFile + +func newEmbeddedCommand() *cli.Command { + return &cli.Command{ + Name: "embedded", + Usage: "Extract embedded resources", + Description: "A command for extracting embedded resources, like templates and images", + Commands: []*cli.Command{ + newEmbeddedListCommand(), + newEmbeddedViewCommand(), + newEmbeddedExtractCommand(), + }, + } +} + +func newEmbeddedListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List files matching the given pattern", + Action: runList, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "include-vendored", + Aliases: []string{"vendor"}, + Usage: "Include files under public/vendor as well", + }, + }, + } +} + +func newEmbeddedViewCommand() *cli.Command { + return &cli.Command{ + Name: "view", + Usage: "View a file matching the given pattern", + Action: runView, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "include-vendored", + Aliases: []string{"vendor"}, + Usage: "Include files under public/vendor as well", + }, + }, + } +} + +func newEmbeddedExtractCommand() *cli.Command { + return &cli.Command{ + Name: "extract", + Usage: "Extract resources", + Action: runExtract, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "include-vendored", + Aliases: []string{"vendor"}, + Usage: "Include files under public/vendor as well", + }, + &cli.BoolFlag{ + Name: "overwrite", + Usage: "Overwrite files if they already exist", + }, + &cli.BoolFlag{ + Name: "rename", + Usage: "Rename files as {name}.bak if they already exist (overwrites previous .bak)", + }, + &cli.BoolFlag{ + Name: "custom", + Usage: "Extract to the 'custom' directory as per app.ini", + }, + &cli.StringFlag{ + Name: "destination", + Aliases: []string{"dest-dir"}, + Usage: "Extract to the specified directory", + }, + }, + } +} + +type assetFile struct { + fs *assetfs.LayeredFS + name string + path string +} + +func initEmbeddedExtractor(c *cli.Command) error { + setupConsoleLogger(log.ERROR, log.CanColorStderr, os.Stderr) + + patterns, err := compileCollectPatterns(c.Args().Slice()) + if err != nil { + return err + } + + collectAssetFilesByPattern(c, patterns, "options", options.BuiltinAssets()) + collectAssetFilesByPattern(c, patterns, "public", public.BuiltinAssets()) + collectAssetFilesByPattern(c, patterns, "templates", templates.BuiltinAssets()) + + return nil +} + +func runList(_ context.Context, c *cli.Command) error { + if err := runListDo(c); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) + return err + } + return nil +} + +func runView(_ context.Context, c *cli.Command) error { + if err := runViewDo(c); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) + return err + } + return nil +} + +func runExtract(_ context.Context, c *cli.Command) error { + if err := runExtractDo(c); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) + return err + } + return nil +} + +func runListDo(c *cli.Command) error { + if err := initEmbeddedExtractor(c); err != nil { + return err + } + + for _, a := range matchedAssetFiles { + cprintln(c, a.path) + } + + return nil +} + +func runViewDo(c *cli.Command) error { + if err := initEmbeddedExtractor(c); err != nil { + return err + } + + if len(matchedAssetFiles) == 0 { + return errors.New("no files matched the given pattern") + } else if len(matchedAssetFiles) > 1 { + return errors.New("too many files matched the given pattern, try to be more specific") + } + + data, err := matchedAssetFiles[0].fs.ReadFile(matchedAssetFiles[0].name) + if err != nil { + return fmt.Errorf("%s: %w", matchedAssetFiles[0].path, err) + } + + if _, err = os.Stdout.Write(data); err != nil { + return fmt.Errorf("%s: %w", matchedAssetFiles[0].path, err) + } + + return nil +} + +func runExtractDo(c *cli.Command) error { + if err := initEmbeddedExtractor(c); err != nil { + return err + } + + if c.NArg() == 0 { + return errors.New("a list of pattern of files to extract is mandatory (e.g. '**' for all)") + } + + destdir := "." + + if c.IsSet("destination") { + destdir = c.String("destination") + } else if c.Bool("custom") { + destdir = setting.CustomPath + cprintln(c, "Using app.ini at", setting.CustomConf) + } + + fi, err := os.Stat(destdir) + if errors.Is(err, os.ErrNotExist) { + // In case Windows users attempt to provide a forward-slash path + wdestdir := filepath.FromSlash(destdir) + if wfi, werr := os.Stat(wdestdir); werr == nil { + destdir = wdestdir + fi = wfi + err = nil + } + } + if err != nil { + return fmt.Errorf("%s: %s", destdir, err) + } else if !fi.IsDir() { + return fmt.Errorf("destination %q is not a directory", destdir) + } + + cprintf(c, "Extracting to %s:\n", destdir) + + overwrite := c.Bool("overwrite") + rename := c.Bool("rename") + + for _, a := range matchedAssetFiles { + if err := extractAsset(c, destdir, a, overwrite, rename); err != nil { + // Non-fatal error + _, _ = fmt.Fprintf(os.Stderr, "%s: %v\n", a.path, err) + } + } + + return nil +} + +func extractAsset(c *cli.Command, d string, a assetFile, overwrite, rename bool) error { + dest := filepath.Join(d, filepath.FromSlash(a.path)) + dir := filepath.Dir(dest) + + data, err := a.fs.ReadFile(a.name) + if err != nil { + return fmt.Errorf("%s: %w", a.path, err) + } + + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("%s: %w", dir, err) + } + + perms := os.ModePerm & 0o666 + + fi, err := os.Lstat(dest) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s: %w", dest, err) + } + } else if !overwrite && !rename { + cprintf(c, "%s already exists; skipped.\n", dest) + return nil + } else if !fi.Mode().IsRegular() { + return fmt.Errorf("%s already exists, but it's not a regular file", dest) + } else if rename { + if err := os.Rename(dest, dest+".bak"); err != nil { + return fmt.Errorf("error creating backup for %s: %w", dest, err) + } + // Attempt to respect file permissions mask (even if user:group will be set anew) + perms = fi.Mode() + } + + file, err := os.OpenFile(dest, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, perms) + if err != nil { + return fmt.Errorf("%s: %w", dest, err) + } + defer file.Close() + + if _, err = file.Write(data); err != nil { + return fmt.Errorf("%s: %w", dest, err) + } + + cprintln(c, dest) + + return nil +} + +func collectAssetFilesByPattern(c *cli.Command, globs []glob.Glob, path string, layer *assetfs.Layer) { + fs := assetfs.Layered(layer) + files, err := fs.ListAllFiles(".", true) + if err != nil { + log.Error("Error listing files in %q: %v", path, err) + return + } + for _, name := range files { + if path == "public" && + strings.HasPrefix(name, "vendor/") && + !c.Bool("include-vendored") { + continue + } + matchName := path + "/" + name + for _, g := range globs { + if g.Match(matchName) { + matchedAssetFiles = append(matchedAssetFiles, assetFile{fs: fs, name: name, path: path + "/" + name}) + break + } + } + } +} + +func compileCollectPatterns(args []string) (_ []glob.Glob, err error) { + if len(args) == 0 { + args = []string{"**"} + } + pat := make([]glob.Glob, len(args)) + for i := range args { + if pat[i], err = glob.Compile(args[i], '/'); err != nil { + return nil, fmt.Errorf("invalid glob pattern %q: %w", args[i], err) + } + } + return pat, nil +} diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..122dbbc --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,181 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "gitea.dev/modules/generate" + "gitea.dev/modules/ssh" + + "github.com/mattn/go-isatty" + "github.com/urfave/cli/v3" +) + +func newGenerateCommand() *cli.Command { + return &cli.Command{ + Name: "generate", + Usage: "Generate Gitea's secrets/keys/tokens", + Commands: []*cli.Command{ + newGenerateSecretCommand(), + newGenerateSSHCommand(), + }, + } +} + +func newGenerateSecretCommand() *cli.Command { + return &cli.Command{ + Name: "secret", + Usage: "Generate a secret token", + Commands: []*cli.Command{ + newGenerateInternalTokenCommand(), + newGenerateLfsJWTSecretCommand(), + newGenerateSecretKeyCommand(), + }, + } +} + +func newGenerateSSHCommand() *cli.Command { + return &cli.Command{ + Name: "ssh", + Usage: "Generate ssh keys", + Commands: []*cli.Command{ + newGenerateSSHKeyCommand(), + newGenerateSSHHostKeysCommand(), + }, + } +} + +func newGenerateInternalTokenCommand() *cli.Command { + return &cli.Command{ + Name: "INTERNAL_TOKEN", + Usage: "Generate a new INTERNAL_TOKEN", + Action: runGenerateInternalToken, + } +} + +func newGenerateLfsJWTSecretCommand() *cli.Command { + return &cli.Command{ + Name: "JWT_SECRET", + Aliases: []string{"LFS_JWT_SECRET"}, + Usage: "Generate a new JWT_SECRET", + Action: runGenerateLfsJwtSecret, + } +} + +func newGenerateSecretKeyCommand() *cli.Command { + return &cli.Command{ + Name: "SECRET_KEY", + Usage: "Generate a new SECRET_KEY", + Action: runGenerateSecretKey, + } +} + +func newGenerateSSHKeyCommand() *cli.Command { + return &cli.Command{ + Name: "key", + Usage: "Generate a new ssh key", + Flags: []cli.Flag{ + &cli.IntFlag{Name: "bits", Aliases: []string{"b"}, Usage: "Number of bits in the key, ignored when key is ed25519"}, + &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Value: "ed25519", Usage: "Specifies the type of key to create."}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "Specifies the path or base directory for the key file", Required: true}, + }, + Action: runGenerateKeyPair, + } +} + +func newGenerateSSHHostKeysCommand() *cli.Command { + return &cli.Command{ + Name: "host-keys", + Usage: "Generate host keys of all default key types (rsa, ecdsa, and ed25519) if they do not already exist.", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "dir", Aliases: []string{"d"}, Usage: "Specifies the base directory for the key files", Required: true}, + }, + Action: runGenerateHostKey, + } +} + +func runGenerateInternalToken(_ context.Context, c *cli.Command) error { + internalToken, err := generate.NewInternalToken() + if err != nil { + return err + } + + cprintf(c, "%s", internalToken) + + if isatty.IsTerminal(os.Stdout.Fd()) { + cprintf(c, "\n") + } + + return nil +} + +func runGenerateLfsJwtSecret(_ context.Context, c *cli.Command) error { + _, jwtSecretBase64 := generate.NewJwtSecretWithBase64() + cprintf(c, "%s", jwtSecretBase64) + + if isatty.IsTerminal(os.Stdout.Fd()) { + cprintf(c, "\n") + } + + return nil +} + +func runGenerateSecretKey(_ context.Context, c *cli.Command) error { + secretKey, err := generate.NewSecretKey() + if err != nil { + return err + } + + // codeql[disable-next-line=go/clear-text-logging] + cprintf(c, "%s", secretKey) + + if isatty.IsTerminal(os.Stdout.Fd()) { + cprintf(c, "\n") + } + + return nil +} + +func runGenerateHostKey(_ context.Context, c *cli.Command) error { + file := c.String("dir") + info, err := os.Stat(file) + if errors.Is(err, os.ErrNotExist) { + if err = os.MkdirAll(file, 0o644); err != nil { + return err + } + } else if err != nil { + return err + } else if !info.IsDir() { + return errors.New("file already exists and is not a directory") + } + fmt.Fprintf(c.Writer, "Generating host keys in %s\n", file) + _, err = ssh.InitDefaultHostKeys(file) + return err +} + +func runGenerateKeyPair(_ context.Context, c *cli.Command) error { + file := c.String("file") + keyType := c.String("type") + + fmt.Fprintf(c.Writer, "Generating public/private %s key pair.\n", keyType) + + // Check if file exists to prevent overwriting + if _, err := os.Stat(file); err == nil { + if !confirm(c.Reader, c.Writer, "%s already exists.\nOverwrite (y/n)? ", file) { + cprintln(c, "Aborting") + return nil + } + } + bits := c.Int("bits") + err := ssh.GenKeyPair(file, generate.SSHKeyType(keyType), bits) + if err == nil { + cprintf(c, "Your SSH key has been saved in %s\n", file) + } + return err +} diff --git a/cmd/helper.go b/cmd/helper.go new file mode 100644 index 0000000..592eda5 --- /dev/null +++ b/cmd/helper.go @@ -0,0 +1,149 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "gitea.dev/models/db" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +// argsSet checks that all the required arguments are set. +// args is a list of arguments that must be set in the command context. +func argsSet(c *cli.Command, args ...string) error { + for _, a := range args { + if !c.IsSet(a) { + return errors.New(a + " is not set") + } + + if c.Value(a) == nil { + return errors.New(a + " is required") + } + } + return nil +} + +// confirm waits for user input which confirms an action +func confirm(stdin io.Reader, stdout io.Writer, msg string, args ...any) bool { + var response string + _, _ = fmt.Fprintf(stdout, msg, args...) + _, _ = fmt.Fscanln(stdin, &response) + switch strings.ToLower(response) { + case "y", "yes": + return true + } + return false +} + +func initDB(ctx context.Context) error { + setting.MustInstalled() + setting.LoadDBSetting() + setting.InitSQLLoggersForCli(log.INFO) + + if setting.Database.Type == "" { + log.Fatal(`Database settings are missing from the configuration file: %q. +Ensure you are running in the correct environment or set the correct configuration file with -c. +If this is the intended configuration file complete the [database] section.`, setting.CustomConf) + } + if err := db.InitEngine(ctx); err != nil { + return fmt.Errorf("unable to initialize the database using the configuration in %q. Error: %w", setting.CustomConf, err) + } + return nil +} + +func installSignals() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + // install notify + signalChannel := make(chan os.Signal, 1) + + signal.Notify( + signalChannel, + syscall.SIGINT, + syscall.SIGTERM, + ) + select { + case <-signalChannel: + case <-ctx.Done(): + } + cancel() + signal.Reset() + }() + + return ctx, cancel +} + +func setupConsoleLogger(level log.Level, colorize bool, out io.Writer) { + if out != os.Stdout && out != os.Stderr { + panic("setupConsoleLogger can only be used with os.Stdout or os.Stderr") + } + + writeMode := log.WriterMode{ + Level: level, + Colorize: colorize, + WriterOption: log.WriterConsoleOption{Stderr: out == os.Stderr}, + } + writer := log.NewEventWriterConsole("console-default", writeMode) + log.GetManager().GetLogger(log.DEFAULT).ReplaceAllWriters(writer) +} + +func globalBool(c *cli.Command, name string) bool { + for _, ctx := range c.Lineage() { + if ctx.Bool(name) { + return true + } + } + return false +} + +// PrepareConsoleLoggerLevel by default, use INFO level for console logger, but some sub-commands (for git/ssh protocol) shouldn't output any log to stdout. +// Any log appears in git stdout pipe will break the git protocol, eg: client can't push and hangs forever. +func PrepareConsoleLoggerLevel(defaultLevel log.Level) func(context.Context, *cli.Command) (context.Context, error) { + return func(ctx context.Context, c *cli.Command) (context.Context, error) { + if setting.InstallLock { + // During config loading, there might also be logs (for example: deprecation warnings). + // It must make sure that console logger is set up before config is loaded. + log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it. CustomConf=%s", setting.CustomConf) + return nil, errors.New("console logger must be setup before config is loaded") + } + level := defaultLevel + if globalBool(c, "quiet") { + level = log.FATAL + } + if globalBool(c, "debug") || globalBool(c, "verbose") { + level = log.TRACE + } + log.SetupStderrLogger(log.DEFAULT, "console-stderr", level) + return ctx, nil + } +} + +func isValidDefaultSubCommand(cmd *cli.Command) (string, bool) { + // Dirty patch for urfave/cli's strange design. + // "./gitea bad-cmd" should not start the web server. + args := cmd.Args().Slice() + if len(args) != 0 { + return args[0], false + } + return "", true +} + +func cprintf(c *cli.Command, format string, args ...any) { + _, _ = fmt.Fprintf(c.Writer, format, args...) +} + +func cprintln(c *cli.Command, args ...any) { + _, _ = fmt.Fprintln(c.Writer, args...) +} diff --git a/cmd/hook.go b/cmd/hook.go new file mode 100644 index 0000000..b0065d2 --- /dev/null +++ b/cmd/hook.go @@ -0,0 +1,782 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "gitea.dev/modules/git" + "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/log" + "gitea.dev/modules/private" + repo_module "gitea.dev/modules/repository" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +const ( + hookBatchSize = 500 +) + +func newHookCommand() *cli.Command { + return &cli.Command{ + Name: "hook", + Usage: "(internal) Should only be called by Git", + Hidden: true, // internal commands shouldn't be visible + Description: "Delegate commands to corresponding Git hooks", + Before: PrepareConsoleLoggerLevel(log.FATAL), + Commands: []*cli.Command{ + newHookPreReceiveCommand(), + newHookUpdateCommand(), + newHookPostReceiveCommand(), + newHookProcReceiveCommand(), + }, + } +} + +func newHookPreReceiveCommand() *cli.Command { + return &cli.Command{ + Name: "pre-receive", + Usage: "Delegate pre-receive Git hook", + Description: "This command should only be called by Git", + Action: runHookPreReceive, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +func newHookUpdateCommand() *cli.Command { + return &cli.Command{ + Name: "update", + Usage: "Delegate update Git hook", + Description: "This command should only be called by Git", + Action: runHookUpdate, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +func newHookPostReceiveCommand() *cli.Command { + return &cli.Command{ + Name: "post-receive", + Usage: "Delegate post-receive Git hook", + Description: "This command should only be called by Git", + Action: runHookPostReceive, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +// Note: new hook since git 2.29 +func newHookProcReceiveCommand() *cli.Command { + return &cli.Command{ + Name: "proc-receive", + Usage: "Delegate proc-receive Git hook", + Description: "This command should only be called by Git", + Action: runHookProcReceive, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +type delayWriter struct { + internal io.Writer + buf *bytes.Buffer + timer *time.Timer +} + +func newDelayWriter(internal io.Writer, delay time.Duration) *delayWriter { + timer := time.NewTimer(delay) + return &delayWriter{ + internal: internal, + buf: &bytes.Buffer{}, + timer: timer, + } +} + +func (d *delayWriter) Write(p []byte) (n int, err error) { + if d.buf != nil { + select { + case <-d.timer.C: + _, err := d.internal.Write(d.buf.Bytes()) + if err != nil { + return 0, err + } + d.buf = nil + return d.internal.Write(p) + default: + return d.buf.Write(p) + } + } + return d.internal.Write(p) +} + +func (d *delayWriter) WriteString(s string) (n int, err error) { + if d.buf != nil { + select { + case <-d.timer.C: + _, err := d.internal.Write(d.buf.Bytes()) + if err != nil { + return 0, err + } + d.buf = nil + return d.internal.Write([]byte(s)) + default: + return d.buf.WriteString(s) + } + } + return d.internal.Write([]byte(s)) +} + +func (d *delayWriter) Close() error { + stopped := d.timer.Stop() + if stopped || d.buf == nil { + return nil + } + _, err := d.internal.Write(d.buf.Bytes()) + d.buf = nil + return err +} + +func parseGitHookCommitRefLine(line string) (oldCommitID, newCommitID string, refFullName git.RefName, ok bool) { + fields := strings.Split(line, " ") + if len(fields) != 3 { + return "", "", "", false + } + return fields[0], fields[1], git.RefName(fields[2]), true +} + +func runHookPreReceive(ctx context.Context, c *cli.Command) error { + if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { + return nil + } + + setup(ctx, c.Bool("debug")) + + if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { + if setting.OnlyAllowPushIfGiteaEnvironmentSet { + return fail(ctx, `Rejecting changes as Gitea environment not set. +If you are pushing over SSH you must push with a key managed by +Gitea or set your environment appropriately.`, "") + } + return nil + } + + // the environment is set by serv command + isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) + ownerName := os.Getenv(repo_module.EnvRepoUsername) + repoName := os.Getenv(repo_module.EnvRepoName) + userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) + prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) + + hookOptions := private.HookOptions{ + IsWiki: isWiki, + + GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), + GitObjectDirectory: os.Getenv(private.GitObjectDirectory), + GitQuarantinePath: os.Getenv(private.GitQuarantinePath), + GitPushOptions: pushOptions(), + + PullRequestID: prID, + + UserID: userID, + UserName: os.Getenv(repo_module.EnvPusherName), + UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData), + } + + scanner := bufio.NewScanner(os.Stdin) + + oldCommitIDs := make([]string, hookBatchSize) + newCommitIDs := make([]string, hookBatchSize) + refFullNames := make([]git.RefName, hookBatchSize) + count := 0 + total := 0 + lastline := 0 + + out := io.Discard + if setting.Git.VerbosePush { + if setting.Git.VerbosePushDelay > 0 { + dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) + defer dWriter.Close() + out = dWriter + } else { + out = os.Stdout + } + } + + supportProcReceive := git.DefaultFeatures().SupportProcReceive + + for scanner.Scan() { + // TODO: support news feeds for wiki + if isWiki { + continue + } + + oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text()) + if !ok { + continue + } + + total++ + lastline++ + + // If the ref is a branch or tag, check if it's protected + // if supportProcReceive all ref should be checked because + // permission check was delayed + if supportProcReceive || refFullName.IsBranch() || refFullName.IsTag() { + oldCommitIDs[count] = oldCommitID + newCommitIDs[count] = newCommitID + refFullNames[count] = refFullName + count++ + fmt.Fprintf(out, "*") + + if count >= hookBatchSize { + fmt.Fprintf(out, " Checking %d references\n", count) + + hookOptions.OldCommitIDs = oldCommitIDs + hookOptions.NewCommitIDs = newCommitIDs + hookOptions.RefFullNames = refFullNames + extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error) + } + count = 0 + lastline = 0 + } + } else { + fmt.Fprintf(out, ".") + } + if lastline >= hookBatchSize { + fmt.Fprintf(out, "\n") + lastline = 0 + } + } + if err := scanner.Err(); err != nil { + return fail(ctx, "Hook failed: stdin read error", "scanner error: %v", err) + } + + if count > 0 { + hookOptions.OldCommitIDs = oldCommitIDs[:count] + hookOptions.NewCommitIDs = newCommitIDs[:count] + hookOptions.RefFullNames = refFullNames[:count] + + fmt.Fprintf(out, " Checking %d references\n", count) + + extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error) + } + } else if lastline > 0 { + fmt.Fprintf(out, "\n") + } + + fmt.Fprintf(out, "Checked %d references in total\n", total) + return nil +} + +// runHookUpdate avoid to do heavy operations on update hook because it will be +// invoked for every ref update which does not like pre-receive and post-receive +func runHookUpdate(_ context.Context, c *cli.Command) error { + if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { + return nil + } + + // Update is empty and is kept only for backwards compatibility + if len(os.Args) < 3 { + return nil + } + refName := git.RefName(os.Args[len(os.Args)-3]) + if refName.IsPull() { + // ignore update to refs/pull/xxx/head, so we don't need to output any information + os.Exit(1) + } + return nil +} + +func runHookPostReceive(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + + // First of all run update-server-info no matter what + if err := gitcmd.NewCommand("update-server-info").RunWithStderr(ctx); err != nil { + return fmt.Errorf("failed to call 'git update-server-info': %w", err) + } + + // Now if we're an internal don't do anything else + if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { + return nil + } + + if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { + if setting.OnlyAllowPushIfGiteaEnvironmentSet { + return fail(ctx, `Rejecting changes as Gitea environment not set. +If you are pushing over SSH you must push with a key managed by +Gitea or set your environment appropriately.`, "") + } + return nil + } + + out := io.Discard + if setting.Git.VerbosePush { + if setting.Git.VerbosePushDelay > 0 { + dWriter := newDelayWriter(os.Stdout, setting.Git.VerbosePushDelay) + defer dWriter.Close() + out = dWriter + } else { + out = os.Stdout + } + } + + // the environment is set by serv command + repoUser := os.Getenv(repo_module.EnvRepoUsername) + isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) + repoName := os.Getenv(repo_module.EnvRepoName) + pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) + prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) + + hookOptions := private.HookOptions{ + IsWiki: isWiki, + + GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), + GitObjectDirectory: os.Getenv(private.GitObjectDirectory), + GitQuarantinePath: os.Getenv(private.GitQuarantinePath), + GitPushOptions: pushOptions(), + + PullRequestID: prID, + PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)), + + UserID: pusherID, + UserName: os.Getenv(repo_module.EnvPusherName), + UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData), + } + + oldCommitIDs := make([]string, 0, hookBatchSize) + newCommitIDs := make([]string, 0, hookBatchSize) + refFullNames := make([]git.RefName, 0, hookBatchSize) + results := make([]private.HookPostReceiveBranchResult, 0) + + defer func() { + hookPrintResults(results) + }() + + processBatch := func() error { + if len(refFullNames) == 0 { + return nil + } + _, _ = fmt.Fprintf(out, " Processing %d references\n", len(refFullNames)) + hookOptions.OldCommitIDs = oldCommitIDs + hookOptions.NewCommitIDs = newCommitIDs + hookOptions.RefFullNames = refFullNames + resp, extra := private.HookPostReceive(ctx, repoUser, repoName, hookOptions) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "HookPostReceive failed: %v", extra.Error) + } + results = append(results, resp.Results...) + oldCommitIDs = oldCommitIDs[:0] + newCommitIDs = newCommitIDs[:0] + refFullNames = refFullNames[:0] + return nil + } + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + // wiki doesn't need "post-receive" at the moment + if isWiki { + continue + } + + oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text()) + if !ok { + continue + } + _, _ = fmt.Fprintf(out, ".") + + oldCommitIDs = append(oldCommitIDs, oldCommitID) + newCommitIDs = append(newCommitIDs, newCommitID) + refFullNames = append(refFullNames, refFullName) + if len(refFullNames) >= hookBatchSize { + // process and start a new batch + if err := processBatch(); err != nil { + return err + } + } + } + if err := scanner.Err(); err != nil { + return fail(ctx, "Hook failed: stdin read error", "scanner error: %v", err) + } + return processBatch() +} + +func hookPrintResults(results []private.HookPostReceiveBranchResult) { + for _, res := range results { + hookPrintResult(res.Message, res.Create, res.Branch, res.URL) + } +} + +func hookPrintResult(output, isCreate bool, branch, url string) { + if !output { + return + } + fmt.Fprintln(os.Stderr, "") + if isCreate { + fmt.Fprintf(os.Stderr, "Create a new pull request for '%s':\n", branch) + fmt.Fprintf(os.Stderr, " %s\n", url) + } else { + fmt.Fprint(os.Stderr, "Visit the existing pull request:\n") + fmt.Fprintf(os.Stderr, " %s\n", url) + } + fmt.Fprintln(os.Stderr, "") + _ = os.Stderr.Sync() +} + +func pushOptions() map[string]string { + opts := make(map[string]string) + if pushCount, err := strconv.Atoi(os.Getenv(private.GitPushOptionCount)); err == nil { + for idx := range pushCount { + opt := os.Getenv(fmt.Sprintf("GIT_PUSH_OPTION_%d", idx)) + kv := strings.SplitN(opt, "=", 2) + if len(kv) == 2 { + opts[kv[0]] = kv[1] + } + } + } + return opts +} + +func runHookProcReceive(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + + if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 { + if setting.OnlyAllowPushIfGiteaEnvironmentSet { + return fail(ctx, `Rejecting changes as Gitea environment not set. +If you are pushing over SSH you must push with a key managed by +Gitea or set your environment appropriately.`, "") + } + return nil + } + + if !git.DefaultFeatures().SupportProcReceive { + return fail(ctx, "No proc-receive support", "current git version doesn't support proc-receive.") + } + + reader := bufio.NewReader(os.Stdin) + repoUser := os.Getenv(repo_module.EnvRepoUsername) + isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) + repoName := os.Getenv(repo_module.EnvRepoName) + pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) + + // 1. Version and features negotiation. + // S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n) + // S: flush-pkt + // H: PKT-LINE(version=1\0push-options...) + // H: flush-pkt + + rs, err := readPktLine(ctx, reader, pktLineTypeData) + if err != nil { + return err + } + + const VersionHead string = "version=1" + + var ( + hasPushOptions bool + response = []byte(VersionHead) + requestOptions []string + ) + + index := bytes.IndexByte(rs.Data, byte(0)) + if index >= len(rs.Data) { + return fail(ctx, "Protocol: format error", "pkt-line: format error %s", rs.Data) + } + + if index < 0 { + if len(rs.Data) == 10 && rs.Data[9] == '\n' { + index = 9 + } else { + return fail(ctx, "Protocol: format error", "pkt-line: format error %s", rs.Data) + } + } + + if string(rs.Data[0:index]) != VersionHead { + return fail(ctx, "Protocol: version error", "Received unsupported version: %s", string(rs.Data[0:index])) + } + requestOptions = strings.Split(string(rs.Data[index+1:]), " ") + + for _, option := range requestOptions { + if strings.HasPrefix(option, "push-options") { + response = append(response, byte(0)) + response = append(response, []byte("push-options")...) + hasPushOptions = true + } + } + response = append(response, '\n') + + _, err = readPktLine(ctx, reader, pktLineTypeFlush) + if err != nil { + return err + } + + err = writeDataPktLine(ctx, os.Stdout, response) + if err != nil { + return err + } + + err = writeFlushPktLine(ctx, os.Stdout) + if err != nil { + return err + } + + // 2. receive commands from server. + // S: PKT-LINE( ) + // S: ... ... + // S: flush-pkt + // # [receive push-options] + // S: PKT-LINE(push-option) + // S: ... ... + // S: flush-pkt + hookOptions := private.HookOptions{ + IsWiki: isWiki, + + GitPushOptions: make(map[string]string), + + UserID: pusherID, + UserName: os.Getenv(repo_module.EnvPusherName), + UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData), + } + hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize) + hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize) + hookOptions.RefFullNames = make([]git.RefName, 0, hookBatchSize) + + for { + // note: pktLineTypeUnknown means pktLineTypeFlush and pktLineTypeData all allowed + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) + if err != nil { + return err + } + + if rs.Type == pktLineTypeFlush { + break + } + t := strings.SplitN(string(rs.Data), " ", 3) + if len(t) != 3 { + continue + } + hookOptions.OldCommitIDs = append(hookOptions.OldCommitIDs, t[0]) + hookOptions.NewCommitIDs = append(hookOptions.NewCommitIDs, t[1]) + hookOptions.RefFullNames = append(hookOptions.RefFullNames, git.RefName(t[2])) + } + + if hasPushOptions { + for { + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) + if err != nil { + return err + } + + if rs.Type == pktLineTypeFlush { + break + } + hookOptions.GitPushOptions.AddFromKeyValue(string(rs.Data)) + } + } + + // 3. run hook + resp, extra := private.HookProcReceive(ctx, repoUser, repoName, hookOptions) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "HookProcReceive failed: %v", extra.Error) + } + + // 4. response result to service + // # a. OK, but has an alternate reference. The alternate reference name + // # and other status can be given in option directives. + // H: PKT-LINE(ok ) + // H: PKT-LINE(option refname ) + // H: PKT-LINE(option old-oid ) + // H: PKT-LINE(option new-oid ) + // H: PKT-LINE(option forced-update) + // H: ... ... + // H: flush-pkt + // # b. NO, I reject it. + // H: PKT-LINE(ng ) + // # c. Fall through, let 'receive-pack' to execute it. + // H: PKT-LINE(ok ) + // H: PKT-LINE(option fall-through) + + for _, rs := range resp.Results { + if len(rs.Err) > 0 { + err = writeDataPktLine(ctx, os.Stdout, []byte("ng "+rs.OriginalRef.String()+" "+rs.Err)) + if err != nil { + return err + } + continue + } + + if rs.IsNotMatched { + err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef.String())) + if err != nil { + return err + } + err = writeDataPktLine(ctx, os.Stdout, []byte("option fall-through")) + if err != nil { + return err + } + continue + } + + err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef)) + if err != nil { + return err + } + err = writeDataPktLine(ctx, os.Stdout, []byte("option refname "+rs.Ref)) + if err != nil { + return err + } + commitID, _ := git.NewIDFromString(rs.OldOID) + if !commitID.IsZero() { + err = writeDataPktLine(ctx, os.Stdout, []byte("option old-oid "+rs.OldOID)) + if err != nil { + return err + } + } + err = writeDataPktLine(ctx, os.Stdout, []byte("option new-oid "+rs.NewOID)) + if err != nil { + return err + } + if rs.IsForcePush { + err = writeDataPktLine(ctx, os.Stdout, []byte("option forced-update")) + if err != nil { + return err + } + } + } + err = writeFlushPktLine(ctx, os.Stdout) + + if err == nil { + for _, res := range resp.Results { + hookPrintResult(res.ShouldShowMessage, res.IsCreatePR, res.HeadBranch, res.URL) + } + } + + return err +} + +// git PKT-Line api +// pktLineType message type of pkt-line +type pktLineType int64 + +const ( + // Unknown type + pktLineTypeUnknown pktLineType = 0 + // flush-pkt "0000" + pktLineTypeFlush pktLineType = iota + // data line + pktLineTypeData +) + +// gitPktLine pkt-line api +type gitPktLine struct { + Type pktLineType + Length uint64 + Data []byte +} + +func readPktLine(ctx context.Context, in *bufio.Reader, requestType pktLineType) (*gitPktLine, error) { + var ( + err error + r *gitPktLine + ) + + // read prefix + lengthBytes := make([]byte, 4) + for i := range 4 { + lengthBytes[i], err = in.ReadByte() + if err != nil { + return nil, fail(ctx, "Protocol: stdin error", "Pkt-Line: read stdin failed : %v", err) + } + } + + r = new(gitPktLine) + r.Length, err = strconv.ParseUint(string(lengthBytes), 16, 32) + if err != nil { + return nil, fail(ctx, "Protocol: format parse error", "Pkt-Line format is wrong :%v", err) + } + + if r.Length == 0 { + if requestType == pktLineTypeData { + return nil, fail(ctx, "Protocol: format data error", "Pkt-Line format is wrong") + } + r.Type = pktLineTypeFlush + return r, nil + } + + if r.Length <= 4 || r.Length > 65520 || requestType == pktLineTypeFlush { + return nil, fail(ctx, "Protocol: format length error", "Pkt-Line format is wrong") + } + + r.Data = make([]byte, r.Length-4) + for i := range r.Data { + r.Data[i], err = in.ReadByte() + if err != nil { + return nil, fail(ctx, "Protocol: data error", "Pkt-Line: read stdin failed : %v", err) + } + } + + r.Type = pktLineTypeData + + return r, nil +} + +func writeFlushPktLine(ctx context.Context, out io.Writer) error { + l, err := out.Write([]byte("0000")) + if err != nil || l != 4 { + return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) + } + return nil +} + +func writeDataPktLine(ctx context.Context, out io.Writer, data []byte) error { + hexchar := []byte("0123456789abcdef") + hex := func(n uint64) byte { + return hexchar[n&15] + } + + length := uint64(len(data) + 4) + tmp := make([]byte, 4) + tmp[0] = hex(length >> 12) + tmp[1] = hex(length >> 8) + tmp[2] = hex(length >> 4) + tmp[3] = hex(length) + + lr, err := out.Write(tmp) + if err != nil || lr != 4 { + return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) + } + + lr, err = out.Write(data) + if err != nil || int(length-4) != lr { + return fail(ctx, "Protocol: write error", "Pkt-Line response failed: %v", err) + } + + return nil +} diff --git a/cmd/hook_test.go b/cmd/hook_test.go new file mode 100644 index 0000000..fefc33c --- /dev/null +++ b/cmd/hook_test.go @@ -0,0 +1,55 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPktLine(t *testing.T) { + // test read + ctx := t.Context() + s := strings.NewReader("0000") + r := bufio.NewReader(s) + result, err := readPktLine(ctx, r, pktLineTypeFlush) + assert.NoError(t, err) + assert.Equal(t, pktLineTypeFlush, result.Type) + + s = strings.NewReader("0006a\n") + r = bufio.NewReader(s) + result, err = readPktLine(ctx, r, pktLineTypeData) + assert.NoError(t, err) + assert.Equal(t, pktLineTypeData, result.Type) + assert.Equal(t, []byte("a\n"), result.Data) + + // test write + w := bytes.NewBuffer([]byte{}) + err = writeFlushPktLine(ctx, w) + assert.NoError(t, err) + assert.Equal(t, []byte("0000"), w.Bytes()) + + w.Reset() + err = writeDataPktLine(ctx, w, []byte("a\nb")) + assert.NoError(t, err) + assert.Equal(t, []byte("0007a\nb"), w.Bytes()) +} + +func TestParseGitHookCommitRefLine(t *testing.T) { + oldCommitID, newCommitID, refName, ok := parseGitHookCommitRefLine("a b c") + assert.True(t, ok) + assert.Equal(t, "a", oldCommitID) + assert.Equal(t, "b", newCommitID) + assert.Equal(t, "c", string(refName)) + + _, _, _, ok = parseGitHookCommitRefLine("a\tb\tc") + assert.False(t, ok) + + _, _, _, ok = parseGitHookCommitRefLine("a b") + assert.False(t, ok) +} diff --git a/cmd/keys.go b/cmd/keys.go new file mode 100644 index 0000000..2f9f52e --- /dev/null +++ b/cmd/keys.go @@ -0,0 +1,84 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "gitea.dev/modules/log" + "gitea.dev/modules/private" + + "github.com/urfave/cli/v3" +) + +// NewKeysCommand returns the internal SSH key lookup sub-command. +func NewKeysCommand() *cli.Command { + return &cli.Command{ + Name: "keys", + Usage: "(internal) Should only be called by SSH server", + Hidden: true, // internal commands shouldn't be visible + Description: "Queries the Gitea database to get the authorized command for a given ssh key fingerprint", + Before: PrepareConsoleLoggerLevel(log.FATAL), + Action: runKeys, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "expected", + Aliases: []string{"e"}, + Value: "git", + Usage: "Expected user for whom provide key commands", + }, + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Value: "", + Usage: "Username trying to log in by SSH", + }, + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Value: "", + Usage: "Type of the SSH key provided to the SSH Server (requires content to be provided too)", + }, + &cli.StringFlag{ + Name: "content", + Aliases: []string{"k"}, + Value: "", + Usage: "Base64 encoded content of the SSH key provided to the SSH Server (requires type to be provided too)", + }, + }, + } +} + +func runKeys(ctx context.Context, c *cli.Command) error { + if !c.IsSet("username") { + return errors.New("No username provided") + } + // Check username matches the expected username + if strings.TrimSpace(c.String("username")) != strings.TrimSpace(c.String("expected")) { + return nil + } + + content := "" + + if c.IsSet("type") && c.IsSet("content") { + content = fmt.Sprintf("%s %s", strings.TrimSpace(c.String("type")), strings.TrimSpace(c.String("content"))) + } + + if content == "" { + return errors.New("No key type and content provided") + } + + setup(ctx, c.Bool("debug")) + + authorizedString, extra := private.AuthorizedPublicKeyByContent(ctx, content) + // do not use handleCliResponseExtra or cli.NewExitError, if it exists immediately, it breaks some tests like Test_CmdKeys + if extra.Error != nil { + return extra.Error + } + _, _ = fmt.Fprintln(c.Root().Writer, strings.TrimSpace(authorizedString.Text)) + return nil +} diff --git a/cmd/mailer.go b/cmd/mailer.go new file mode 100644 index 0000000..d4c94f1 --- /dev/null +++ b/cmd/mailer.go @@ -0,0 +1,39 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/modules/private" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func runSendMail(ctx context.Context, c *cli.Command) error { + setting.MustInstalled() + + subject := c.String("title") + confirmSkipped := c.Bool("force") + body := c.String("content") + + if !confirmSkipped { + if len(body) == 0 { + cprintln(c, "warning: Content is empty") + } + + if !confirm(c.Reader, c.Writer, "Proceed with sending email? [Y/n] ") { + cprintln(c, "The mail was not sent") + return nil + } + } + + respText, extra := private.SendEmail(ctx, subject, body, nil) + if extra.HasError() { + return handleCliResponseExtra(extra) + } + cprintf(c, "Sent %s email(s) to all users\n", respText.Text) + return nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..66b2fba --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,193 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +var cliHelpPrinterOld = cli.HelpPrinter + +func init() { + cli.HelpPrinter = cliHelpPrinterNew +} + +// cliHelpPrinterNew helps to print "DEFAULT CONFIGURATION" for the following cases ( "-c" can apper in any position): +// * ./gitea -c /dev/null -h +// * ./gitea -c help /dev/null help +// * ./gitea help -c /dev/null +// * ./gitea help -c /dev/null web +// * ./gitea help web -c /dev/null +// * ./gitea web help -c /dev/null +// * ./gitea web -h -c /dev/null +func cliHelpPrinterNew(out io.Writer, templ string, data any) { + cmd, _ := data.(*cli.Command) + if cmd != nil { + prepareWorkPathAndCustomConf(cmd) + } + cliHelpPrinterOld(out, templ, data) + if setting.CustomConf != "" { + _, _ = fmt.Fprintf(out, ` +DEFAULT CONFIGURATION: + AppPath: %s + WorkPath: %s + CustomPath: %s + ConfigFile: %s + +`, setting.AppPath, setting.AppWorkPath, setting.CustomPath, setting.CustomConf) + } +} + +func PrepareSubcommandWithGlobalFlags(originCmd *cli.Command) { + originBefore := originCmd.Before + originCmd.Before = func(ctxOrig context.Context, cmd *cli.Command) (ctx context.Context, err error) { + ctx = ctxOrig + if originBefore != nil { + ctx, err = originBefore(ctx, cmd) + if err != nil { + return ctx, err + } + } + prepareWorkPathAndCustomConf(cmd) + return ctx, nil + } +} + +// prepareWorkPathAndCustomConf tries to prepare the work path, custom path and custom config from various inputs: +// command line flags, environment variables, config file +func prepareWorkPathAndCustomConf(cmd *cli.Command) { + var args setting.ArgWorkPathAndCustomConf + if cmd.IsSet("work-path") { + args.WorkPath = cmd.String("work-path") + } + if cmd.IsSet("custom-path") { + args.CustomPath = cmd.String("custom-path") + } + if cmd.IsSet("config") { + args.CustomConf = cmd.String("config") + } + setting.InitWorkPathAndCommonConfig(os.Getenv, args) +} + +type AppVersion struct { + Version string + Extra string +} + +func NewMainApp(appVer AppVersion) *cli.Command { + app := &cli.Command{} + app.Name = "gitea" // must be lower-cased because it appears in the "USAGE" section like "gitea doctor [command [command options]]" + app.Usage = "A painless self-hosted Git service" + app.Description = `Gitea program contains "web" and other subcommands. If no subcommand is given, it starts the web server by default. Use "web" subcommand for more web server arguments, use other subcommands for other purposes.` + app.Version = appVer.Version + appVer.Extra + app.EnableShellCompletion = true + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "work-path", + Aliases: []string{"w"}, + TakesFile: true, + Usage: "Set Gitea's working path (defaults to the Gitea's binary directory)", + }, + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + TakesFile: true, + Value: setting.CustomConf, + Usage: "Set custom config file (defaults to '{WorkPath}/custom/conf/app.ini')", + }, + &cli.StringFlag{ + Name: "custom-path", + Aliases: []string{"C"}, + TakesFile: true, + Usage: "Set custom path (defaults to '{WorkPath}/custom')", + }, + } + webCmd := newWebCommand() + // these sub-commands need to use a config file + subCmdWithConfig := []*cli.Command{ + webCmd, + newServCommand(), + newHookCommand(), + NewKeysCommand(), + newDumpCommand(), + newAdminCommand(), + newMigrateCommand(), + newDoctorCommand(), + newManagerCommand(), + newEmbeddedCommand(), + newMigrateStorageCommand(), + newDumpRepositoryCommand(), + newRestoreRepositoryCommand(), + newActionsCommand(), + } + + // these sub-commands do not need the config file, and they do not depend on any path or environment variable. + subCmdStandalone := []*cli.Command{ + cmdConfig(), + cmdCert(), + newGenerateCommand(), + newDocsCommand(), + } + + // TODO: we should eventually drop the default command, + // but not sure whether it would break Windows users who used to double-click the EXE to run. + app.DefaultCommand = webCmd.Name + + app.Before = PrepareConsoleLoggerLevel(log.INFO) + for i := range subCmdWithConfig { + PrepareSubcommandWithGlobalFlags(subCmdWithConfig[i]) + } + app.Commands = append(app.Commands, subCmdWithConfig...) + app.Commands = append(app.Commands, subCmdStandalone...) + + setting.UnsetUnnecessaryEnvVars() + return app +} + +// usageErr marks a usage error already reported by cliOnUsageError, so RunMainApp does not print it again. +type usageErr struct{ err error } + +func (e usageErr) Error() string { return e.err.Error() } +func (e usageErr) Unwrap() error { return e.err } + +// cliOnUsageError reports usage errors itself instead of letting urfave/cli dump the full help to stdout (since urfave/cli v3.10). +func cliOnUsageError(_ context.Context, cmd *cli.Command, err error, _ bool) error { + _, _ = fmt.Fprintf(cmd.Root().ErrWriter, "Incorrect Usage: %s\n", err.Error()) + return usageErr{err} +} + +func setCLIOnUsageError(cmd *cli.Command) { + _ = cmd.Walk(func(c *cli.Command) error { + c.OnUsageError = cliOnUsageError + return nil + }) +} + +func RunMainApp(app *cli.Command, args ...string) error { + ctx, cancel := installSignals() + defer cancel() + setCLIOnUsageError(app) + // the completion subcommands are built during app.Run, after the Walk above, so cover them via this hook + app.ConfigureShellCompletionCommand = setCLIOnUsageError + err := app.Run(ctx, args) + if err == nil { + return nil + } + if _, ok := errors.AsType[usageErr](err); ok { + cli.OsExiter(1) // cliOnUsageError already reported it + return err + } + _, _ = fmt.Fprintf(app.ErrWriter, "Command error: %v\n", err) + cli.OsExiter(1) + return err +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..0476980 --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func TestDefaultCommand(t *testing.T) { + test := func(t *testing.T, args []string, expectedRetName string, expectedRetValid bool) { + called := false + cmd := &cli.Command{ + DefaultCommand: "test", + Commands: []*cli.Command{ + { + Name: "test", + Action: func(ctx context.Context, command *cli.Command) error { + retName, retValid := isValidDefaultSubCommand(command) + assert.Equal(t, expectedRetName, retName) + assert.Equal(t, expectedRetValid, retValid) + called = true + return nil + }, + }, + }, + } + assert.NoError(t, cmd.Run(t.Context(), args)) + assert.True(t, called) + } + test(t, []string{"./gitea"}, "", true) + test(t, []string{"./gitea", "test"}, "", true) + test(t, []string{"./gitea", "other"}, "other", false) + test(t, []string{"./gitea", "test", "extra"}, "extra", false) +} diff --git a/cmd/manager.go b/cmd/manager.go new file mode 100644 index 0000000..85fccc9 --- /dev/null +++ b/cmd/manager.go @@ -0,0 +1,154 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "os" + "time" + + "gitea.dev/modules/private" + + "github.com/urfave/cli/v3" +) + +func newManagerCommand() *cli.Command { + return &cli.Command{ + Name: "manager", + Usage: "Manage the running gitea process", + Description: "This is a command for managing the running gitea process", + Commands: []*cli.Command{ + newShutdownCommand(), + newRestartCommand(), + newReloadTemplatesCommand(), + newFlushQueuesCommand(), + newLoggingCommand(), + newProcessesCommand(), + }, + } +} + +func newShutdownCommand() *cli.Command { + return &cli.Command{ + Name: "shutdown", + Usage: "Gracefully shutdown the running process", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runShutdown, + } +} + +func newRestartCommand() *cli.Command { + return &cli.Command{ + Name: "restart", + Usage: "Gracefully restart the running process - (not implemented for windows servers)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runRestart, + } +} + +func newReloadTemplatesCommand() *cli.Command { + return &cli.Command{ + Name: "reload-templates", + Usage: "Reload template files in the running process", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runReloadTemplates, + } +} + +func newFlushQueuesCommand() *cli.Command { + return &cli.Command{ + Name: "flush-queues", + Usage: "Flush queues in the running process", + Action: runFlushQueues, + Flags: []cli.Flag{ + &cli.DurationFlag{ + Name: "timeout", + Value: 60 * time.Second, + Usage: "Timeout for the flushing process", + }, + &cli.BoolFlag{ + Name: "non-blocking", + Usage: "Set to true to not wait for flush to complete before returning", + }, + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +func newProcessesCommand() *cli.Command { + return &cli.Command{ + Name: "processes", + Usage: "Display running processes within the current process", + Action: runProcesses, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + &cli.BoolFlag{ + Name: "flat", + Usage: "Show processes as flat table rather than as tree", + }, + &cli.BoolFlag{ + Name: "no-system", + Usage: "Do not show system processes", + }, + &cli.BoolFlag{ + Name: "stacktraces", + Usage: "Show stacktraces", + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Output as json", + }, + &cli.StringFlag{ + Name: "cancel", + Usage: "Process PID to cancel. (Only available for non-system processes.)", + }, + }, + } +} + +func runShutdown(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + extra := private.Shutdown(ctx) + return handleCliResponseExtra(extra) +} + +func runRestart(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + extra := private.Restart(ctx) + return handleCliResponseExtra(extra) +} + +func runReloadTemplates(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + extra := private.ReloadTemplates(ctx) + return handleCliResponseExtra(extra) +} + +func runFlushQueues(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + extra := private.FlushQueues(ctx, c.Duration("timeout"), c.Bool("non-blocking")) + return handleCliResponseExtra(extra) +} + +func runProcesses(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + extra := private.Processes(ctx, os.Stdout, c.Bool("flat"), c.Bool("no-system"), c.Bool("stacktraces"), c.Bool("json"), c.String("cancel")) + return handleCliResponseExtra(extra) +} diff --git a/cmd/manager_logging.go b/cmd/manager_logging.go new file mode 100644 index 0000000..a54041f --- /dev/null +++ b/cmd/manager_logging.go @@ -0,0 +1,92 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "os" + + "gitea.dev/modules/private" + + "github.com/urfave/cli/v3" +) + +func newLoggingCommand() *cli.Command { + return &cli.Command{ + Name: "logging", + Usage: "Adjust logging commands", + Commands: []*cli.Command{ + { + Name: "pause", + Usage: "Pause logging (Gitea will buffer logs up to a certain point and will drop them after that point)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runPauseLogging, + }, { + Name: "resume", + Usage: "Resume logging", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runResumeLogging, + }, { + Name: "release-and-reopen", + Usage: "Cause Gitea to release and re-open files used for logging", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + }, + Action: runReleaseReopenLogging, + }, { + Name: "log-sql", + Usage: "Set LogSQL", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + }, + &cli.BoolFlag{ + Name: "off", + Usage: "Switch off SQL logging", + }, + }, + Action: runSetLogSQL, + }, + }, + } +} + +func runPauseLogging(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + userMsg := private.PauseLogging(ctx) + _, _ = fmt.Fprintln(os.Stdout, userMsg) + return nil +} + +func runResumeLogging(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + userMsg := private.ResumeLogging(ctx) + _, _ = fmt.Fprintln(os.Stdout, userMsg) + return nil +} + +func runReleaseReopenLogging(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + userMsg := private.ReleaseReopenLogging(ctx) + _, _ = fmt.Fprintln(os.Stdout, userMsg) + return nil +} + +func runSetLogSQL(ctx context.Context, c *cli.Command) error { + setup(ctx, c.Bool("debug")) + + extra := private.SetLogSQL(ctx, !c.Bool("off")) + return handleCliResponseExtra(extra) +} diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..cc4fee3 --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,43 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "gitea.dev/models/db" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + "gitea.dev/services/versioned_migration" + + "github.com/urfave/cli/v3" +) + +func newMigrateCommand() *cli.Command { + return &cli.Command{ + Name: "migrate", + Usage: "Migrate the database", + Description: `This is a command for migrating the database, so that you can run "gitea admin create user" before starting the server.`, + Action: runMigrate, + } +} + +func runMigrate(ctx context.Context, c *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + log.Info("AppPath: %s", setting.AppPath) + log.Info("AppWorkPath: %s", setting.AppWorkPath) + log.Info("Custom path: %s", setting.CustomPath) + log.Info("Log path: %s", setting.Log.RootPath) + log.Info("Configuration file: %s", setting.CustomConf) + + if err := db.InitEngineWithMigration(context.Background(), versioned_migration.Migrate); err != nil { + log.Fatal("Failed to initialize ORM engine: %v", err) + return err + } + + return nil +} diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go new file mode 100644 index 0000000..36b224f --- /dev/null +++ b/cmd/migrate_storage.go @@ -0,0 +1,310 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strings" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + git_model "gitea.dev/models/git" + packages_model "gitea.dev/models/packages" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/log" + packages_module "gitea.dev/modules/packages" + "gitea.dev/modules/setting" + "gitea.dev/modules/storage" + "gitea.dev/services/versioned_migration" + + "github.com/urfave/cli/v3" +) + +func newMigrateStorageCommand() *cli.Command { + return &cli.Command{ + Name: "migrate-storage", + Usage: "Migrate the storage", + Description: "Copies stored files from storage configured in app.ini to parameter-configured storage", + Action: runMigrateStorage, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Value: "", + Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts'", + }, + &cli.StringFlag{ + Name: "storage", + Aliases: []string{"s"}, + Value: "", + Usage: "New storage type: local (default), minio or azureblob", + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Value: "", + Usage: "New storage placement if store is local (leave blank for default)", + }, + // Minio Storage special configurations + &cli.StringFlag{ + Name: "minio-endpoint", + Value: "", + Usage: "Minio storage endpoint", + }, + &cli.StringFlag{ + Name: "minio-access-key-id", + Value: "", + Usage: "Minio storage accessKeyID", + }, + &cli.StringFlag{ + Name: "minio-secret-access-key", + Value: "", + Usage: "Minio storage secretAccessKey", + }, + &cli.StringFlag{ + Name: "minio-bucket", + Value: "", + Usage: "Minio storage bucket", + }, + &cli.StringFlag{ + Name: "minio-location", + Value: "", + Usage: "Minio storage location to create bucket", + }, + &cli.StringFlag{ + Name: "minio-base-path", + Value: "", + Usage: "Minio storage base path on the bucket", + }, + &cli.BoolFlag{ + Name: "minio-use-ssl", + Usage: "Enable SSL for minio", + }, + &cli.BoolFlag{ + Name: "minio-insecure-skip-verify", + Usage: "Skip SSL verification", + }, + &cli.StringFlag{ + Name: "minio-checksum-algorithm", + Value: "", + Usage: "Minio checksum algorithm (default/md5)", + }, + &cli.StringFlag{ + Name: "minio-bucket-lookup-type", + Value: "", + Usage: "Minio bucket lookup type", + }, + // Azure Blob Storage special configurations + &cli.StringFlag{ + Name: "azureblob-endpoint", + Value: "", + Usage: "Azure Blob storage endpoint", + }, + &cli.StringFlag{ + Name: "azureblob-account-name", + Value: "", + Usage: "Azure Blob storage account name", + }, + &cli.StringFlag{ + Name: "azureblob-account-key", + Value: "", + Usage: "Azure Blob storage account key", + }, + &cli.StringFlag{ + Name: "azureblob-container", + Value: "", + Usage: "Azure Blob storage container", + }, + &cli.StringFlag{ + Name: "azureblob-base-path", + Value: "", + Usage: "Azure Blob storage base path", + }, + }, + } +} + +func migrateAttachments(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, attach *repo_model.Attachment) error { + _, err := storage.Copy(dstStorage, attach.RelativePath(), storage.Attachments, attach.RelativePath()) + return err + }) +} + +func migrateLFS(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, mo *git_model.LFSMetaObject) error { + _, err := storage.Copy(dstStorage, mo.RelativePath(), storage.LFS, mo.RelativePath()) + return err + }) +} + +func migrateAvatars(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, user *user_model.User) error { + if user.CustomAvatarRelativePath() == "" { + return nil + } + _, err := storage.Copy(dstStorage, user.CustomAvatarRelativePath(), storage.Avatars, user.CustomAvatarRelativePath()) + return err + }) +} + +func migrateRepoAvatars(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, repo *repo_model.Repository) error { + if repo.CustomAvatarRelativePath() == "" { + return nil + } + _, err := storage.Copy(dstStorage, repo.CustomAvatarRelativePath(), storage.RepoAvatars, repo.CustomAvatarRelativePath()) + return err + }) +} + +func migrateRepoArchivers(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, archiver *repo_model.RepoArchiver) error { + p := archiver.RelativePath() + _, err := storage.Copy(dstStorage, p, storage.RepoArchives, p) + return err + }) +} + +func migratePackages(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, pb *packages_model.PackageBlob) error { + p := packages_module.KeyToRelativePath(packages_module.BlobHash256Key(pb.HashSHA256)) + _, err := storage.Copy(dstStorage, p, storage.Packages, p) + return err + }) +} + +func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, task *actions_model.ActionTask) error { + if task.LogExpired { + // the log has been cleared + return nil + } + if !task.LogInStorage { + // running tasks store logs in DBFS + return nil + } + p := task.LogFilename + _, err := storage.Copy(dstStorage, p, storage.Actions, p) + return err + }) +} + +func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error { + if artifact.Status == actions_model.ArtifactStatusExpired { + return nil + } + + _, err := storage.Copy(dstStorage, artifact.StoragePath, storage.ActionsArtifacts, artifact.StoragePath) + if err != nil { + // ignore files that do not exist + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + + return nil + }) +} + +func runMigrateStorage(ctx context.Context, cmd *cli.Command) error { + if err := initDB(ctx); err != nil { + return err + } + + log.Info("AppPath: %s", setting.AppPath) + log.Info("AppWorkPath: %s", setting.AppWorkPath) + log.Info("Custom path: %s", setting.CustomPath) + log.Info("Log path: %s", setting.Log.RootPath) + log.Info("Configuration file: %s", setting.CustomConf) + + if err := db.InitEngineWithMigration(context.Background(), versioned_migration.Migrate); err != nil { + log.Fatal("Failed to initialize ORM engine: %v", err) + return err + } + + if err := storage.Init(); err != nil { + return err + } + + var dstStorage storage.ObjectStorage + var err error + switch strings.ToLower(cmd.String("storage")) { + case "": + fallthrough + case string(setting.LocalStorageType): + p := cmd.String("path") + if p == "" { + log.Fatal("Path must be given when storage is local") + return nil + } + dstStorage, err = storage.NewLocalStorage( + ctx, + &setting.Storage{ + Path: p, + }) + case string(setting.MinioStorageType): + dstStorage, err = storage.NewMinioStorage( + ctx, + &setting.Storage{ + MinioConfig: setting.MinioStorageConfig{ + Endpoint: cmd.String("minio-endpoint"), + AccessKeyID: cmd.String("minio-access-key-id"), + SecretAccessKey: cmd.String("minio-secret-access-key"), + Bucket: cmd.String("minio-bucket"), + Location: cmd.String("minio-location"), + BasePath: cmd.String("minio-base-path"), + UseSSL: cmd.Bool("minio-use-ssl"), + InsecureSkipVerify: cmd.Bool("minio-insecure-skip-verify"), + ChecksumAlgorithm: cmd.String("minio-checksum-algorithm"), + BucketLookUpType: cmd.String("minio-bucket-lookup-type"), + }, + }) + case string(setting.AzureBlobStorageType): + dstStorage, err = storage.NewAzureBlobStorage( + ctx, + &setting.Storage{ + AzureBlobConfig: setting.AzureBlobStorageConfig{ + Endpoint: cmd.String("azureblob-endpoint"), + AccountName: cmd.String("azureblob-account-name"), + AccountKey: cmd.String("azureblob-account-key"), + Container: cmd.String("azureblob-container"), + BasePath: cmd.String("azureblob-base-path"), + }, + }) + default: + return fmt.Errorf("unsupported storage type: %s", cmd.String("storage")) + } + if err != nil { + return err + } + + migratedMethods := map[string]func(context.Context, storage.ObjectStorage) error{ + "attachments": migrateAttachments, + "lfs": migrateLFS, + "avatars": migrateAvatars, + "repo-avatars": migrateRepoAvatars, + "repo-archivers": migrateRepoArchivers, + "packages": migratePackages, + "actions-log": migrateActionsLog, + "actions-artifacts": migrateActionsArtifacts, + } + + tp := strings.ToLower(cmd.String("type")) + if m, ok := migratedMethods[tp]; ok { + if err := m(ctx, dstStorage); err != nil { + return err + } + log.Info("%s files have successfully been copied to the new storage.", tp) + return nil + } + + return fmt.Errorf("unsupported storage: %s", cmd.String("type")) +} diff --git a/cmd/migrate_storage_test.go b/cmd/migrate_storage_test.go new file mode 100644 index 0000000..67a6b9e --- /dev/null +++ b/cmd/migrate_storage_test.go @@ -0,0 +1,73 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "strings" + "testing" + + "gitea.dev/models/packages" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + packages_module "gitea.dev/modules/packages" + "gitea.dev/modules/setting" + "gitea.dev/modules/storage" + packages_service "gitea.dev/services/packages" + + "github.com/stretchr/testify/assert" +) + +func TestMigratePackages(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + content := "package main\n\nfunc main() {\nfmt.Println(\"hi\")\n}\n" + buf, err := packages_module.CreateHashedBufferFromReaderWithSize(strings.NewReader(content), 1024) + assert.NoError(t, err) + defer buf.Close() + + v, f, err := packages_service.CreatePackageAndAddFile(t.Context(), &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: creator, + PackageType: packages.TypeGeneric, + Name: "test", + Version: "1.0.0", + }, + Creator: creator, + SemverCompatible: true, + VersionProperties: map[string]string{}, + }, &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: "a.go", + }, + Creator: creator, + Data: buf, + IsLead: true, + }) + assert.NoError(t, err) + assert.NotNil(t, v) + assert.NotNil(t, f) + + ctx := t.Context() + + p := t.TempDir() + + dstStorage, err := storage.NewLocalStorage( + ctx, + &setting.Storage{ + Path: p, + }) + assert.NoError(t, err) + + err = migratePackages(ctx, dstStorage) + assert.NoError(t, err) + + entries, err := os.ReadDir(p) + assert.NoError(t, err) + assert.Len(t, entries, 2) + assert.Equal(t, "01", entries[0].Name()) + assert.Equal(t, "tmp", entries[1].Name()) +} diff --git a/cmd/restore_repo.go b/cmd/restore_repo.go new file mode 100644 index 0000000..c7bbddd --- /dev/null +++ b/cmd/restore_repo.go @@ -0,0 +1,68 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "strings" + + "gitea.dev/modules/private" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func newRestoreRepositoryCommand() *cli.Command { + return &cli.Command{ + Name: "restore-repo", + Usage: "Restore the repository from disk", + Description: "This is a command for restoring the repository data.", + Action: runRestoreRepository, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "repo_dir", + Aliases: []string{"r"}, + Value: "./data", + Usage: "Repository dir path to restore from", + }, + &cli.StringFlag{ + Name: "owner_name", + Value: "", + Usage: "Restore destination owner name", + }, + &cli.StringFlag{ + Name: "repo_name", + Value: "", + Usage: "Restore destination repository name", + }, + &cli.StringFlag{ + Name: "units", + Value: "", + Usage: `Which items will be restored, one or more units should be separated as comma. +wiki, issues, labels, releases, release_assets, milestones, pull_requests, comments are allowed. Empty means all units.`, + }, + &cli.BoolFlag{ + Name: "validation", + Usage: "Sanity check the content of the files before trying to load them", + }, + }, + } +} + +func runRestoreRepository(ctx context.Context, c *cli.Command) error { + setting.MustInstalled() + var units []string + if s := c.String("units"); s != "" { + units = strings.Split(s, ",") + } + extra := private.RestoreRepo( + ctx, + c.String("repo_dir"), + c.String("owner_name"), + c.String("repo_name"), + units, + c.Bool("validation"), + ) + return handleCliResponseExtra(extra) +} diff --git a/cmd/serv.go b/cmd/serv.go new file mode 100644 index 0000000..583d534 --- /dev/null +++ b/cmd/serv.go @@ -0,0 +1,352 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" + + asymkey_model "gitea.dev/models/asymkey" + git_model "gitea.dev/models/git" + "gitea.dev/models/perm" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/git" + "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/json" + "gitea.dev/modules/lfstransfer" + "gitea.dev/modules/log" + "gitea.dev/modules/pprof" + "gitea.dev/modules/private" + "gitea.dev/modules/process" + repo_module "gitea.dev/modules/repository" + "gitea.dev/modules/setting" + "gitea.dev/services/agit" + "gitea.dev/services/lfs" + + "github.com/kballard/go-shellquote" + "github.com/urfave/cli/v3" +) + +func newServCommand() *cli.Command { + return &cli.Command{ + Name: "serv", + Usage: "(internal) Should only be called by SSH shell", + Description: "Serv provides access auth for repositories", + Hidden: true, // Internal commands shouldn't be visible in help + Before: PrepareConsoleLoggerLevel(log.FATAL), + Action: runServ, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "enable-pprof", + }, + &cli.BoolFlag{ + Name: "debug", + }, + }, + } +} + +func setup(ctx context.Context, debug bool) { + if debug { + setupConsoleLogger(log.TRACE, false, os.Stderr) + } else { + setupConsoleLogger(log.FATAL, false, os.Stderr) + } + setting.MustInstalled() + if _, err := os.Stat(setting.RepoRootPath); err != nil { + _ = fail(ctx, "Unable to access repository path", "Unable to access repository path %q, err: %v", setting.RepoRootPath, err) + return + } + if err := git.InitSimple(); err != nil { + _ = fail(ctx, "Failed to init git", "Failed to init git, err: %v", err) + } +} + +// fail prints message to stdout, it's mainly used for git serv and git hook commands. +// The output will be passed to git client and shown to user. +func fail(ctx context.Context, userMessage, logMsgFmt string, args ...any) error { + if userMessage == "" { + userMessage = "Internal Server Error (no specific error)" + } + + // There appears to be a chance to cause a zombie process and failure to read the Exit status + // if nothing is outputted on stdout. + _, _ = fmt.Fprintln(os.Stdout, "") + // add extra empty lines to separate our message from other git errors to get more attention + _, _ = fmt.Fprintln(os.Stderr, "error:") + _, _ = fmt.Fprintln(os.Stderr, "error:", userMessage) + _, _ = fmt.Fprintln(os.Stderr, "error:") + + if logMsgFmt != "" { + logMsg := fmt.Sprintf(logMsgFmt, args...) + if !setting.IsProd { + _, _ = fmt.Fprintln(os.Stderr, "Gitea:", logMsg) + } + if unicode.IsPunct(rune(userMessage[len(userMessage)-1])) { + logMsg = userMessage + " " + logMsg + } else { + logMsg = userMessage + ". " + logMsg + } + _ = private.SSHLog(ctx, true, logMsg) + } + return cli.Exit("", 1) +} + +// handleCliResponseExtra handles the extra response from the cli sub-commands +// If there is a user message it will be printed to stdout +// If the command failed it will return an error (the error will be printed by cli framework) +func handleCliResponseExtra(extra private.ResponseExtra) error { + if extra.UserMsg != "" { + _, _ = fmt.Fprintln(os.Stdout, extra.UserMsg) + } + if extra.HasError() { + return cli.Exit(extra.Error, 1) + } + return nil +} + +// getAccessMode maps an SSH git/LFS verb to the access mode it requires, with +// ok=false for an unrecognised verb. Callers MUST reject the request when ok is +// false: AccessModeNone would otherwise pass the `userMode < mode` permission +// check in routers/private/serv.go and grant access. +func getAccessMode(verb, lfsVerb string) (mode perm.AccessMode, ok bool) { + switch verb { + case git.CmdVerbUploadPack, git.CmdVerbUploadArchive: + return perm.AccessModeRead, true + case git.CmdVerbReceivePack: + return perm.AccessModeWrite, true + case git.CmdVerbLfsAuthenticate, git.CmdVerbLfsTransfer: + switch lfsVerb { + case git.CmdSubVerbLfsUpload: + return perm.AccessModeWrite, true + case git.CmdSubVerbLfsDownload: + return perm.AccessModeRead, true + } + } + return perm.AccessModeNone, false +} + +func runServ(ctx context.Context, c *cli.Command) error { + // FIXME: This needs to internationalised + setup(ctx, c.Bool("debug")) + + if setting.SSH.Disabled { + cprintln(c, "Gitea: SSH has been disabled") + return nil + } + + if c.NArg() < 1 { + if err := cli.ShowSubcommandHelp(c); err != nil { + cprintf(c, "error showing subcommand help: %v\n", err) + } + return nil + } + + defer func() { + if err := recover(); err != nil { + _ = fail(ctx, "Internal Server Error", "Panic: %v\n%s", err, log.Stack(2)) + } + }() + + keys := strings.Split(c.Args().First(), "-") + if len(keys) != 2 || keys[0] != "key" { + return fail(ctx, "Key ID format error", "Invalid key argument: %s", c.Args().First()) + } + keyID, err := strconv.ParseInt(keys[1], 10, 64) + if err != nil { + return fail(ctx, "Key ID parsing error", "Invalid key argument: %s", c.Args().Get(1)) + } + + cmd := os.Getenv("SSH_ORIGINAL_COMMAND") + if len(cmd) == 0 { + key, user, err := private.ServNoCommand(ctx, keyID) + if err != nil { + return fail(ctx, "Key check failed", "Failed to check provided key: %v", err) + } + var authSuccessMsg string + switch key.Type { + case asymkey_model.KeyTypeDeploy: + authSuccessMsg = "Hi there! You've successfully authenticated with an SSH deploy key." + case asymkey_model.KeyTypePrincipal: + authSuccessMsg = "Hi there! You've successfully authenticated with the SSH principal " + key.Content + "." + default: + authSuccessMsg = "Hi there, " + user.Name + "! You've successfully authenticated with the SSH key named " + key.Name + "." + } + _, _ = fmt.Fprintf(c.ErrWriter, "%s\n%s", + authSuccessMsg, + "Gitea does not provide shell access. If this is unexpected, please setup Gitea under another SSH user or use container to deploy.", + ) + return nil + } else if c.Bool("debug") { + log.Debug("SSH_ORIGINAL_COMMAND: %s", os.Getenv("SSH_ORIGINAL_COMMAND")) + } + + sshCmdArgs, err := shellquote.Split(cmd) + if err != nil { + return fail(ctx, "Error parsing arguments", "Failed to parse arguments: %v", err) + } + + if len(sshCmdArgs) < 2 { + if git.DefaultFeatures().SupportProcReceive { + // for AGit Flow + if cmd == "ssh_info" { + cprintf(c, "%s", agit.SshInfoJson) + return nil + } + } + return fail(ctx, "Too few arguments", "Too few arguments in cmd: %s", cmd) + } + + var reqOwnerName, reqRepoName string + { + var ok bool + reqRepoPath := strings.TrimPrefix(sshCmdArgs[1], "/") + reqOwnerName, reqRepoName, ok = strings.Cut(reqRepoPath, "/") + if !ok { + return fail(ctx, "Invalid repository path", "Invalid repository path: %v", reqRepoPath) + } + reqRepoName = strings.TrimSuffix(reqRepoName, ".git") // "the-repo-name" or "the-repo-name.wiki" + } + + if !repo_model.IsValidSSHAccessRepoName(reqRepoName) { + return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reqRepoName) + } + + if c.Bool("enable-pprof") { + stopProfiler, err := pprof.DumpPprofForUsername(setting.PprofDataPath, reqOwnerName) + if err != nil { + return fail(ctx, "Unable to start pprof profiler", "Unable to start pprof profile: %v", err) + } + defer stopProfiler() + } + + verb, lfsVerb := sshCmdArgs[0], "" + if !git.IsAllowedVerbForServe(verb) { + return fail(ctx, "Unknown git command", "Unknown git command %s", verb) + } + + if git.IsAllowedVerbForServeLfs(verb) { + if !setting.LFS.StartServer { + return fail(ctx, "LFS Server is not enabled", "") + } + if verb == git.CmdVerbLfsTransfer && !setting.LFS.AllowPureSSH { + return fail(ctx, "LFS SSH transfer is not enabled", "") + } + if len(sshCmdArgs) > 2 { + lfsVerb = sshCmdArgs[2] + } + } + + requestedMode, ok := getAccessMode(verb, lfsVerb) + if !ok { + return fail(ctx, "Unknown git command", "Unknown git command %s %s", verb, lfsVerb) + } + + results, extra := private.ServCommand(ctx, keyID, reqOwnerName, reqRepoName, requestedMode, verb, lfsVerb) + if extra.HasError() { + return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error) + } + + // LFS SSH protocol + if verb == git.CmdVerbLfsTransfer { + if results.IsWiki { + return fail(ctx, "LFS Transfer is not supported for wikis", "") + } + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID}) + if err != nil { + return err + } + return lfstransfer.Main(ctx, results.OwnerName, results.RepoName, lfsVerb, token) + } + + // LFS token authentication + if verb == git.CmdVerbLfsAuthenticate { + if results.IsWiki { + return fail(ctx, "LFS Authenticate is not supported for wikis", "") + } + lfsTokenHref := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) + + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID}) + if err != nil { + return err + } + + tokenAuthentication := &git_model.LFSTokenResponse{ + Header: make(map[string]string), + Href: lfsTokenHref, + } + tokenAuthentication.Header["Authorization"] = token + + enc := json.NewEncoder(os.Stdout) + err = enc.Encode(tokenAuthentication) + if err != nil { + return fail(ctx, "Failed to encode LFS json response", "Failed to encode LFS json response: %v", err) + } + return nil + } + + var command *process.Cmd + gitBinPath := filepath.Dir(gitcmd.GitExecutable) // e.g. /usr/bin + gitBinVerb := filepath.Join(gitBinPath, verb) // e.g. /usr/bin/git-upload-pack + if _, err := os.Stat(gitBinVerb); err != nil { + // if the command "git-upload-pack" doesn't exist, try to split "git-upload-pack" to use the sub-command with git + // ps: Windows only has "git.exe" in the bin path, so Windows always uses this way + verbFields := strings.SplitN(verb, "-", 2) + if len(verbFields) == 2 { + // use git binary with the sub-command part: "C:\...\bin\git.exe", "upload-pack", ... + command = process.CommandContext(ctx, gitcmd.GitExecutable, verbFields[1], results.RepoStoragePath) + } + } + if command == nil { + // by default, use the verb (it has been checked above by allowedCommands) + command = process.CommandContext(ctx, gitBinVerb, results.RepoStoragePath) + } + + command.Dir = setting.RepoRootPath + command.Stdout = os.Stdout + command.Stdin = os.Stdin + command.Stderr = os.Stderr + command.Env = append(command.Env, os.Environ()...) + command.Env = append(command.Env, + repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki), + + repo_module.EnvRepoUsername+"="+results.OwnerName, + repo_module.EnvRepoName+"="+results.RepoName, + repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10), + + repo_module.EnvKeyID+"="+strconv.FormatInt(results.PublicKeyID, 10), + + repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10), + repo_module.EnvPusherName+"="+results.UserName, + repo_module.EnvPusherEmail+"="+results.UserEmail, + repo_module.EnvPusherExtDoerData+"="+results.UserExtDoerData, + + repo_module.EnvPRID+"="+strconv.Itoa(0), + + repo_module.EnvAppURL+"="+setting.AppURL, + ) + // to avoid breaking, here only use the minimal environment variables for the "gitea serv" command. + // it could be re-considered whether to use the same git.CommonGitCmdEnvs() as "git" command later. + command.Env = append(command.Env, gitcmd.CommonCmdServEnvs()...) + + if err = command.Run(); err != nil { + return fail(ctx, "Failed to execute git command", "Failed to execute git command: %v", err) + } + + // Update user key activity. + if results.PublicKeyID > 0 { + if err = private.UpdatePublicKeyInRepo(ctx, results.PublicKeyID, results.RepoID); err != nil { + return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err) + } + } + + return nil +} diff --git a/cmd/serv_test.go b/cmd/serv_test.go new file mode 100644 index 0000000..3bdcba1 --- /dev/null +++ b/cmd/serv_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "testing" + + "gitea.dev/models/perm" + "gitea.dev/modules/git" + + "github.com/stretchr/testify/assert" +) + +func TestGetAccessMode(t *testing.T) { + cases := []struct { + verb, lfsVerb string + expected perm.AccessMode + }{ + {git.CmdVerbUploadPack, "", perm.AccessModeRead}, + {git.CmdVerbUploadArchive, "", perm.AccessModeRead}, + {git.CmdVerbReceivePack, "", perm.AccessModeWrite}, + {git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsUpload, perm.AccessModeWrite}, + {git.CmdVerbLfsAuthenticate, git.CmdSubVerbLfsDownload, perm.AccessModeRead}, + {git.CmdVerbLfsTransfer, git.CmdSubVerbLfsUpload, perm.AccessModeWrite}, + {git.CmdVerbLfsTransfer, git.CmdSubVerbLfsDownload, perm.AccessModeRead}, + } + for _, tc := range cases { + t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) { + mode, ok := getAccessMode(tc.verb, tc.lfsVerb) + assert.True(t, ok) + assert.Equal(t, tc.expected, mode) + }) + } +} + +// TestGetAccessModeUnknownVerb locks in the invariant that getAccessMode reports +// ok=false for unrecognised verbs and LFS sub-verbs, so runServ rejects them. An +// unknown verb has no valid access mode; if it were treated as AccessModeNone (0) +// it would pass the `userMode < mode` permission check in routers/private/serv.go +// and hand out valid LFS JWTs for any private repository. +func TestGetAccessModeUnknownVerb(t *testing.T) { + cases := []struct{ verb, lfsVerb string }{ + {git.CmdVerbLfsAuthenticate, ""}, + {git.CmdVerbLfsAuthenticate, "badverb"}, + {git.CmdVerbLfsTransfer, "badverb"}, + {"git-unknown-verb", ""}, + } + for _, tc := range cases { + t.Run(tc.verb+"/"+tc.lfsVerb, func(t *testing.T) { + mode, ok := getAccessMode(tc.verb, tc.lfsVerb) + assert.False(t, ok) + assert.Equal(t, perm.AccessModeNone, mode) + }) + } +} diff --git a/cmd/web.go b/cmd/web.go new file mode 100644 index 0000000..11c5d83 --- /dev/null +++ b/cmd/web.go @@ -0,0 +1,392 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/pprof" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + audit_model "gitea.dev/models/audit" + "gitea.dev/modules/container" + "gitea.dev/modules/graceful" + "gitea.dev/modules/gtprof" + "gitea.dev/modules/log" + "gitea.dev/modules/process" + "gitea.dev/modules/public" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + "gitea.dev/routers" + "gitea.dev/routers/install" + "gitea.dev/services/audit" + + "github.com/felixge/fgprof" + "github.com/urfave/cli/v3" +) + +// PIDFile could be set from build tag +var PIDFile = "/run/gitea.pid" + +func newWebCommand() *cli.Command { + return &cli.Command{ + Name: "web", + Usage: "Start Gitea web server", + Description: `Gitea web server is the only thing you need to run, +and it takes care of all the other things for you`, + Before: PrepareConsoleLoggerLevel(log.INFO), + Action: runWeb, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "port", + Aliases: []string{"p"}, + Value: "3000", + Usage: "Temporary port number to prevent conflict", + }, + &cli.StringFlag{ + Name: "install-port", + Value: "3000", + Usage: "Temporary port number to run the install page on to prevent conflict", + }, + &cli.StringFlag{ + Name: "pid", + Aliases: []string{"P"}, + Value: PIDFile, + Usage: "Custom pid file path", + }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Only display Fatal logging errors until logging is set-up", + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Set initial logging to TRACE level until logging is properly set-up", + }, + }, + } +} + +func runHTTPRedirector() { + _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Web: HTTP Redirector", process.SystemProcessType, true) + defer finished() + + source := fmt.Sprintf("%s:%s", setting.HTTPAddr, setting.PortToRedirect) + dest := strings.TrimSuffix(setting.AppURL, "/") + log.Info("Redirecting: %s to %s", source, dest) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := dest + r.URL.Path + if len(r.URL.RawQuery) > 0 { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusTemporaryRedirect) + }) + + err := runHTTP("tcp", source, "HTTP Redirector", handler, setting.RedirectorUseProxyProtocol) + if err != nil { + log.Fatal("Failed to start port redirection: %v", err) + } +} + +func createPIDFile(pidPath string) { + currentPid := os.Getpid() + if err := os.MkdirAll(filepath.Dir(pidPath), os.ModePerm); err != nil { + log.Fatal("Failed to create PID folder: %v", err) + } + + file, err := os.Create(pidPath) + if err != nil { + log.Fatal("Failed to create PID file: %v", err) + } + defer file.Close() + if _, err := file.WriteString(strconv.FormatInt(int64(currentPid), 10)); err != nil { + log.Fatal("Failed to write PID information: %v", err) + } +} + +func showWebStartupMessage(msg string) { + log.Info("Gitea version: %s%s", setting.AppVer, setting.AppBuiltWith) + log.Info("* RunMode: %s", setting.RunMode) + log.Info("* AppPath: %s", setting.AppPath) + log.Info("* WorkPath: %s", setting.AppWorkPath) + log.Info("* CustomPath: %s", setting.CustomPath) + log.Info("* ConfigFile: %s", setting.CustomConf) + log.Info("%s", msg) // show startup message + + if setting.CORSConfig.Enabled { + log.Info("CORS Service Enabled") + } + if setting.DefaultUILocation != time.Local { + log.Info("Default UI Location is %v", setting.DefaultUILocation.String()) + } + if setting.MailService != nil { + log.Info("Mail Service Enabled: RegisterEmailConfirm=%v, Service.EnableNotifyMail=%v", setting.Service.RegisterEmailConfirm, setting.Service.EnableNotifyMail) + } +} + +func serveInstall(cmd *cli.Command) error { + showWebStartupMessage("Prepare to run install page") + + routers.InitWebInstallPage(graceful.GetManager().HammerContext()) + + // Flag for port number in case first time run conflict + if cmd.IsSet("port") { + if err := setPort(cmd.String("port")); err != nil { + return err + } + } + if cmd.IsSet("install-port") { + if err := setPort(cmd.String("install-port")); err != nil { + return err + } + } + c := install.Routes() + err := listen(c, false) + if err != nil { + log.Error("Unable to open listener for installer. Is Gitea already running?") + graceful.GetManager().DoGracefulShutdown() + } + select { + case <-graceful.GetManager().IsShutdown(): + <-graceful.GetManager().Done() + log.Info("PID: %d Gitea Web Finished", os.Getpid()) + return err + default: + } + return nil +} + +func serveInstalled(c *cli.Command) error { + setting.MustInstalled() + + showWebStartupMessage("Prepare to run web server") + + if setting.AppWorkPathMismatch { + log.Error("WORK_PATH from config %q doesn't match other paths from environment variables or command arguments. "+ + "Only WORK_PATH in config should be set and used. Please make sure the path in config file is correct, "+ + "remove the other outdated work paths from environment variables and command arguments", setting.CustomConf) + } + + rootCfg := setting.CfgProvider + if rootCfg.Section("").Key("WORK_PATH").String() == "" { + saveCfg, err := rootCfg.PrepareSaving() + if err != nil { + log.Error("Unable to prepare saving WORK_PATH=%s to config %q: %v\nYou should set it manually, otherwise there might be bugs when accessing the git repositories.", setting.AppWorkPath, setting.CustomConf, err) + } else { + rootCfg.Section("").Key("WORK_PATH").SetValue(setting.AppWorkPath) + saveCfg.Section("").Key("WORK_PATH").SetValue(setting.AppWorkPath) + if err = saveCfg.Save(); err != nil { + log.Error("Unable to update WORK_PATH=%s to config %q: %v\nYou should set it manually, otherwise there might be bugs when accessing the git repositories.", setting.AppWorkPath, setting.CustomConf, err) + } + } + } + + // in old versions, user's custom web files are placed in "custom/public", and they were served as "http://domain.com/assets/xxx" + // now, Gitea only serves pre-defined files in the "custom/public" folder basing on the web root, the user should move their custom files to "custom/public/assets" + publicFiles, _ := public.AssetFS().ListFiles(".") + publicFilesSet := container.SetOf(publicFiles...) + publicFilesSet.Remove(".well-known") + publicFilesSet.Remove("assets") + publicFilesSet.Remove("robots.txt") + for _, fn := range publicFilesSet.Values() { + log.Error("Found legacy public asset %q in CustomPath. Please move it to %s/public/assets/%s", fn, setting.CustomPath, fn) + } + if _, err := os.Stat(filepath.Join(setting.CustomPath, "robots.txt")); err == nil { + log.Error(`Found legacy public asset "robots.txt" in CustomPath. Please move it to %s/public/robots.txt`, setting.CustomPath) + } + + routers.InitWebInstalled(graceful.GetManager().HammerContext()) + + // We check that AppDataPath exists here (it should have been created during installation) + // We can't check it in `InitWebInstalled`, because some integration tests + // use cmd -> InitWebInstalled, but the AppDataPath doesn't exist during those tests. + if _, err := os.Stat(setting.AppDataPath); err != nil { + log.Fatal("Can not find APP_DATA_PATH %q", setting.AppDataPath) + } + + // the AppDataTempDir is fully managed by us with a safe sub-path + // so it's safe to automatically remove the outdated files + setting.AppDataTempDir("").RemoveOutdated(3 * 24 * time.Hour) + + // Override the provided port number within the configuration + if c.IsSet("port") { + if err := setPort(c.String("port")); err != nil { + return err + } + } + + gtprof.EnableBuiltinTracer(util.Iif(setting.IsProd, 2000*time.Millisecond, 100*time.Millisecond)) + + // Set up Chi routes + webRoutes := routers.NormalRoutes() + + auditCtx := cliAuditContext(context.Background()) + log.Info("Audit record output: %s", setting.Audit.RecordOutput) + audit.Record(auditCtx, audit_model.SystemStartup, nil, "version", setting.AppVer) + + err := listen(webRoutes, true) + + audit.Record(auditCtx, audit_model.SystemShutdown, nil) + <-graceful.GetManager().Done() + log.Info("PID: %d Gitea Web Finished", os.Getpid()) + return err +} + +func servePprof() { + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + mux.Handle("/debug/fgprof", fgprof.Handler()) + // FIXME: it should use a proper context + _, _, finished := process.GetManager().AddTypedContext(context.TODO(), "Web: PProf Server", process.SystemProcessType, true) + // The pprof server is for debug purpose only, it shouldn't be exposed on public network. At the moment, it's not worth introducing a configurable option for it. + log.Info("Starting pprof server on localhost:6060") + server := &http.Server{Addr: "localhost:6060", Handler: mux, ReadHeaderTimeout: 10 * time.Second} + log.Info("Stopped pprof server: %v", server.ListenAndServe()) + finished() +} + +func runWeb(ctx context.Context, cmd *cli.Command) error { + if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid { + return fmt.Errorf("unknown command: %s", subCmdName) + } + + managerCtx, cancel := context.WithCancel(ctx) + graceful.InitManager(managerCtx) + defer cancel() + + if os.Getppid() > 1 && len(os.Getenv("LISTEN_FDS")) > 0 { + log.Info("Restarting Gitea on PID: %d from parent PID: %d", os.Getpid(), os.Getppid()) + } else { + log.Info("Starting Gitea on PID: %d", os.Getpid()) + } + + // Set pid file setting + if cmd.IsSet("pid") { + createPIDFile(cmd.String("pid")) + } + + // init the HTML renderer and load templates, if error happens, it will report the error immediately and exit with error log + // in dev mode, it won't exit, but watch the template files for changes + _ = templates.PageRenderer() + + if !setting.InstallLock { + if err := serveInstall(cmd); err != nil { + return err + } + } else { + NoInstallListener() + } + + if setting.EnablePprof { + go servePprof() + } + + return serveInstalled(cmd) +} + +func setPort(port string) error { + setting.AppURL = strings.Replace(setting.AppURL, setting.HTTPPort, port, 1) + setting.HTTPPort = port + + switch setting.Protocol { + case setting.HTTPUnix: + case setting.FCGI: + case setting.FCGIUnix: + default: + defaultLocalURL := string(setting.Protocol) + "://" + if setting.HTTPAddr == "0.0.0.0" { + defaultLocalURL += "localhost" + } else { + defaultLocalURL += setting.HTTPAddr + } + defaultLocalURL += ":" + setting.HTTPPort + "/" + + // Save LOCAL_ROOT_URL if port changed + rootCfg := setting.CfgProvider + saveCfg, err := rootCfg.PrepareSaving() + if err != nil { + return fmt.Errorf("failed to save config file: %v", err) + } + rootCfg.Section("server").Key("LOCAL_ROOT_URL").SetValue(defaultLocalURL) + saveCfg.Section("server").Key("LOCAL_ROOT_URL").SetValue(defaultLocalURL) + if err = saveCfg.Save(); err != nil { + return fmt.Errorf("failed to save config file: %v", err) + } + } + return nil +} + +func listen(m http.Handler, handleRedirector bool) error { + listenAddr := setting.HTTPAddr + if setting.Protocol != setting.HTTPUnix && setting.Protocol != setting.FCGIUnix { + listenAddr = net.JoinHostPort(listenAddr, setting.HTTPPort) + } + _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Web: Gitea Server", process.SystemProcessType, true) + defer finished() + log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubURL) + // This can be useful for users, many users do wrong to their config and get strange behaviors behind a reverse-proxy. + // A user may fix the configuration mistake when he sees this log. + // And this is also very helpful to maintainers to provide help to users to resolve their configuration problems. + log.Info("AppURL(ROOT_URL): %s", setting.AppURL) + + if setting.LFS.StartServer { + log.Info("LFS server enabled") + } + + var err error + switch setting.Protocol { + case setting.HTTP: + if handleRedirector { + NoHTTPRedirector() + } + err = runHTTP("tcp", listenAddr, "Web", m, setting.UseProxyProtocol) + case setting.HTTPS: + if setting.EnableAcme { + err = runACME(listenAddr, m) + break + } + if handleRedirector { + if setting.RedirectOtherPort { + go runHTTPRedirector() + } else { + NoHTTPRedirector() + } + } + err = runHTTPS("tcp", listenAddr, "Web", setting.CertFile, setting.KeyFile, m, setting.UseProxyProtocol, setting.ProxyProtocolTLSBridging) + case setting.FCGI: + if handleRedirector { + NoHTTPRedirector() + } + err = runFCGI("tcp", listenAddr, "FCGI Web", m, setting.UseProxyProtocol) + case setting.HTTPUnix: + if handleRedirector { + NoHTTPRedirector() + } + err = runHTTP("unix", listenAddr, "Web", m, setting.UseProxyProtocol) + case setting.FCGIUnix: + if handleRedirector { + NoHTTPRedirector() + } + err = runFCGI("unix", listenAddr, "Web", m, setting.UseProxyProtocol) + default: + log.Fatal("Invalid protocol: %s", setting.Protocol) + } + if err != nil { + log.Error("Failed to start server: %v", err) + } + log.Info("HTTP Listener: %s Closed", listenAddr) + return err +} diff --git a/cmd/web_acme.go b/cmd/web_acme.go new file mode 100644 index 0000000..10a00cc --- /dev/null +++ b/cmd/web_acme.go @@ -0,0 +1,144 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net" + "net/http" + "os" + "strconv" + "strings" + + "gitea.dev/modules/graceful" + "gitea.dev/modules/log" + "gitea.dev/modules/process" + "gitea.dev/modules/setting" + "gitea.dev/modules/util" + + "github.com/caddyserver/certmagic" +) + +func getCARoot(path string) (*x509.CertPool, error) { + r, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(r) + if block == nil { + return nil, fmt.Errorf("no PEM found in the file %s", path) + } + caRoot, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + certPool := x509.NewCertPool() + certPool.AddCert(caRoot) + return certPool, nil +} + +func runACME(listenAddr string, m http.Handler) error { + // If HTTP Challenge enabled, needs to be serving on port 80. For TLSALPN needs 443. + // Due to docker port mapping this can't be checked programmatically + // TODO: these are placeholders until we add options for each in settings with appropriate warning + enableHTTPChallenge := true + enableTLSALPNChallenge := true + altHTTPPort := 0 + altTLSALPNPort := 0 + + if p, err := strconv.Atoi(setting.PortToRedirect); err == nil { + altHTTPPort = p + } + if p, err := strconv.Atoi(setting.HTTPPort); err == nil { + altTLSALPNPort = p + } + + // Try to use private CA root if provided, otherwise defaults to system's trust + var certPool *x509.CertPool + if setting.AcmeCARoot != "" { + var err error + certPool, err = getCARoot(setting.AcmeCARoot) + if err != nil { + log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err) + } + } + // FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https" + // Ideally it should migrate to AppDataPath write to "AppDataPath/https" + // And one more thing, no idea why we should set the global default variables here + // But it seems that the current ACME code needs these global variables to make renew work. + // Otherwise, "renew" will use incorrect storage path + oldDefaultACME := certmagic.DefaultACME + certmagic.Default.Storage = &certmagic.FileStorage{Path: setting.AcmeLiveDirectory} + certmagic.DefaultACME = certmagic.ACMEIssuer{ + // try to use the default values provided by DefaultACME + CA: util.IfZero(setting.AcmeURL, oldDefaultACME.CA), + TestCA: oldDefaultACME.TestCA, + Logger: oldDefaultACME.Logger, + HTTPProxy: oldDefaultACME.HTTPProxy, + + TrustedRoots: certPool, + Email: setting.AcmeEmail, + Agreed: setting.AcmeTOS, + DisableHTTPChallenge: !enableHTTPChallenge, + DisableTLSALPNChallenge: !enableTLSALPNChallenge, + ListenHost: setting.HTTPAddr, + AltTLSALPNPort: altTLSALPNPort, + AltHTTPPort: altHTTPPort, + } + + magic := certmagic.NewDefault() + myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME) + magic.Issuers = []certmagic.Issuer{myACME} + + // Obtain certificates or renew them if necessary. ManageSync fails closed on + // renewal errors even when a still-valid certificate is already on disk, which + // takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519). + // Prefer keeping the existing cert and retrying renewals asynchronously. + ctx := graceful.GetManager().ShutdownContext() + err := magic.ManageSync(ctx, []string{setting.Domain}) + if err != nil { + cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.Domain) + if cacheErr != nil || cert.Expired() { + return errors.Join(err, cacheErr) + } + log.Error("ACME certificate manage failed; continuing with existing certificate: %v", err) + if err := magic.ManageAsync(ctx, []string{setting.Domain}); err != nil { + log.Error("Failed to start async ACME management: %v", err) + } + } + + // certmagic only advertises its own ACME challenge protocol, applyTLSSettings appends ours + tlsConfig := applyTLSSettings(magic.TLSConfig()) + + if enableHTTPChallenge { + go func() { + _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Web: ACME HTTP challenge server", process.SystemProcessType, true) + defer finished() + + log.Info("Running Let's Encrypt handler on %s", setting.HTTPAddr+":"+setting.PortToRedirect) + // all traffic coming into HTTP will be redirected to HTTPS automatically (LE HTTP-01 validation happens here) + err := runHTTP("tcp", net.JoinHostPort(setting.HTTPAddr, setting.PortToRedirect), "Let's Encrypt HTTP Challenge", myACME.HTTPChallengeHandler(http.HandlerFunc(runLetsEncryptFallbackHandler)), setting.RedirectorUseProxyProtocol) + if err != nil { + log.Fatal("Failed to start the Let's Encrypt handler on port %s: %v", setting.PortToRedirect, err) + } + }() + } + + return runHTTPSWithTLSConfig("tcp", listenAddr, "Web", tlsConfig, m, setting.UseProxyProtocol, setting.ProxyProtocolTLSBridging) +} + +func runLetsEncryptFallbackHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Use HTTPS", http.StatusBadRequest) + return + } + // Remove the trailing slash at the end of setting.AppURL, the request + // URI always contains a leading slash, which would result in a double + // slash + target := strings.TrimSuffix(setting.AppURL, "/") + r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusTemporaryRedirect) +} diff --git a/cmd/web_graceful.go b/cmd/web_graceful.go new file mode 100644 index 0000000..13eb9dd --- /dev/null +++ b/cmd/web_graceful.go @@ -0,0 +1,49 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "net" + "net/http" + "net/http/fcgi" + "strings" + + "gitea.dev/modules/graceful" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" +) + +func runHTTP(network, listenAddr, name string, m http.Handler, useProxyProtocol bool) error { + return graceful.HTTPListenAndServe(network, listenAddr, name, m, useProxyProtocol) +} + +// NoHTTPRedirector tells our cleanup routine that we will not be using a fallback http redirector +func NoHTTPRedirector() { + graceful.GetManager().InformCleanup() +} + +// NoInstallListener tells our cleanup routine that we will not be using a possibly provided listener +// for our install HTTP/HTTPS service +func NoInstallListener() { + graceful.GetManager().InformCleanup() +} + +func runFCGI(network, listenAddr, name string, m http.Handler, useProxyProtocol bool) error { + // This needs to handle stdin as fcgi point + fcgiServer := graceful.NewServer(network, listenAddr, name) + + err := fcgiServer.ListenAndServe(func(listener net.Listener) error { + return fcgi.Serve(listener, http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + if setting.AppSubURL != "" { + req.URL.Path = strings.TrimPrefix(req.URL.Path, setting.AppSubURL) + } + m.ServeHTTP(resp, req) + })) + }, useProxyProtocol) + if err != nil { + log.Fatal("Failed to start FCGI main server: %v", err) + } + log.Info("FCGI Listener: %s Closed", listenAddr) + return err +} diff --git a/cmd/web_https.go b/cmd/web_https.go new file mode 100644 index 0000000..907c0ea --- /dev/null +++ b/cmd/web_https.go @@ -0,0 +1,125 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "crypto/tls" + "net/http" + "os" + "strings" + + "gitea.dev/modules/graceful" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" +) + +var tlsVersionStringMap = map[string]uint16{ + "tlsv1.0": tls.VersionTLS10, + "tlsv1.1": tls.VersionTLS11, + "tlsv1.2": tls.VersionTLS12, + "tlsv1.3": tls.VersionTLS13, +} + +// toTLSVersion returns 0 when unset or unknown, which keeps the crypto/tls default +func toTLSVersion(version string) uint16 { + version = strings.TrimSpace(strings.ToLower(version)) + tlsVersion, ok := tlsVersionStringMap[version] + if !ok && version != "" { + log.Warn("Unknown tls version: %s", version) + } + return tlsVersion +} + +var curveStringMap = map[string]tls.CurveID{ + "x25519": tls.X25519, + "p256": tls.CurveP256, + "p384": tls.CurveP384, + "p521": tls.CurveP521, + "mlkem1024": tls.MLKEM1024, + "x25519mlkem768": tls.X25519MLKEM768, + "secp256r1mlkem768": tls.SecP256r1MLKEM768, + "secp384r1mlkem1024": tls.SecP384r1MLKEM1024, +} + +// lookupAll resolves configured names, warning about and skipping the ones crypto/tls does not know +func lookupAll[T comparable](names []string, table map[string]T, kind string) []T { + values := make([]T, 0, len(names)) + for _, name := range names { + value, ok := table[strings.TrimSpace(strings.ToLower(name))] + if !ok { + log.Warn("Unknown %s: %s", kind, name) + continue + } + values = append(values, value) + } + return values +} + +// cipherStringMap is derived from crypto/tls so that suites Go adds or removes are picked up automatically +var cipherStringMap = buildCipherStringMap() + +func buildCipherStringMap() map[string]uint16 { + ciphers := map[string]uint16{ + // aliases for the two suites Go later renamed with a _SHA256 suffix + "ecdhe_rsa_with_chacha20_poly1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + "ecdhe_ecdsa_with_chacha20_poly1305": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + } + for _, cipher := range append(tls.CipherSuites(), tls.InsecureCipherSuites()...) { + ciphers[strings.ToLower(strings.TrimPrefix(cipher.Name, "TLS_"))] = cipher.ID + } + return ciphers +} + +// applyTLSSettings applies the configured [server] TLS options. Every option +// left unset keeps the crypto/tls default, which Go keeps current. +func applyTLSSettings(tlsConfig *tls.Config) *tls.Config { + // the listener is already TLS wrapped when it reaches http.Server, so it never sets ALPN for us + tlsConfig.NextProtos = append(tlsConfig.NextProtos, "h2", "http/1.1") + tlsConfig.MinVersion = toTLSVersion(setting.SSLMinimumVersion) + tlsConfig.MaxVersion = toTLSVersion(setting.SSLMaximumVersion) + + if curves := lookupAll(setting.SSLCurvePreferences, curveStringMap, "curve"); len(curves) > 0 { + tlsConfig.CurvePreferences = curves + } + if ciphers := lookupAll(setting.SSLCipherSuites, cipherStringMap, "cipher suite"); len(ciphers) > 0 { + tlsConfig.CipherSuites = ciphers + } + return tlsConfig +} + +// runHTTPS listens on the provided network address and then calls +// Serve to handle requests on incoming TLS connections. +// +// Filenames containing a certificate and matching private key for the server must +// be provided. If the certificate is signed by a certificate authority, the +// certFile should be the concatenation of the server's certificate followed by the +// CA's certificate. +func runHTTPS(network, listenAddr, name, certFile, keyFile string, m http.Handler, useProxyProtocol, proxyProtocolTLSBridging bool) error { + tlsConfig := applyTLSSettings(&tls.Config{}) + tlsConfig.Certificates = make([]tls.Certificate, 1) + + certPEMBlock, err := os.ReadFile(certFile) + if err != nil { + log.Error("Failed to load https cert file %s for %s:%s: %v", certFile, network, listenAddr, err) + return err + } + + keyPEMBlock, err := os.ReadFile(keyFile) + if err != nil { + log.Error("Failed to load https key file %s for %s:%s: %v", keyFile, network, listenAddr, err) + return err + } + + tlsConfig.Certificates[0], err = tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + log.Error("Failed to create certificate from cert file %s and key file %s for %s:%s: %v", certFile, keyFile, network, listenAddr, err) + return err + } + + return graceful.HTTPListenAndServeTLSConfig(network, listenAddr, name, tlsConfig, m, useProxyProtocol, proxyProtocolTLSBridging) +} + +func runHTTPSWithTLSConfig(network, listenAddr, name string, tlsConfig *tls.Config, m http.Handler, useProxyProtocol, proxyProtocolTLSBridging bool) error { + return graceful.HTTPListenAndServeTLSConfig(network, listenAddr, name, tlsConfig, m, useProxyProtocol, proxyProtocolTLSBridging) +} diff --git a/cmd/web_https_test.go b/cmd/web_https_test.go new file mode 100644 index 0000000..a6bc4dc --- /dev/null +++ b/cmd/web_https_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "crypto/tls" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToTLSVersion(t *testing.T) { + assert.Zero(t, toTLSVersion(""), "unset must keep the crypto/tls default") + assert.Zero(t, toTLSVersion("unknown")) + assert.Equal(t, uint16(tls.VersionTLS12), toTLSVersion(" TLSV1.2 ")) +} + +func unsetTLSSettings(t *testing.T) { + t.Helper() + t.Cleanup(test.MockVariableValue(&setting.SSLMinimumVersion, "")) + t.Cleanup(test.MockVariableValue(&setting.SSLMaximumVersion, "")) + t.Cleanup(test.MockVariableValue(&setting.SSLCurvePreferences, nil)) + t.Cleanup(test.MockVariableValue(&setting.SSLCipherSuites, nil)) +} + +// TestApplyTLSSettingsUnsetKeepsGoDefaults guards against reintroducing hardcoded defaults, +// which is how the server ended up capped at TLS 1.2 and without post-quantum key exchange. +// It asserts the fields are left alone rather than which algorithms Go then picks, so a new +// Go release changing its preferences does not fail this test. +func TestApplyTLSSettingsUnsetKeepsGoDefaults(t *testing.T) { + unsetTLSSettings(t) + + tlsConfig := applyTLSSettings(&tls.Config{}) + assert.Zero(t, tlsConfig.MinVersion) + assert.Zero(t, tlsConfig.MaxVersion) + assert.Nil(t, tlsConfig.CurvePreferences) + assert.Nil(t, tlsConfig.CipherSuites) + assert.Equal(t, []string{"h2", "http/1.1"}, tlsConfig.NextProtos) +} + +func TestApplyTLSSettingsConfigured(t *testing.T) { + t.Cleanup(test.MockVariableValue(&setting.SSLMinimumVersion, "tlsv1.2")) + t.Cleanup(test.MockVariableValue(&setting.SSLMaximumVersion, "tlsv1.3")) + t.Cleanup(test.MockVariableValue(&setting.SSLCurvePreferences, []string{"p384"})) + t.Cleanup(test.MockVariableValue(&setting.SSLCipherSuites, []string{"ecdhe_rsa_with_aes_256_gcm_sha384"})) + + tlsConfig := applyTLSSettings(&tls.Config{}) + assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), tlsConfig.MaxVersion) + assert.Equal(t, []tls.CurveID{tls.CurveP384}, tlsConfig.CurvePreferences) + assert.Equal(t, []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384}, tlsConfig.CipherSuites) +} + +func TestHTTPSServesTLS13(t *testing.T) { + unsetTLSSettings(t) + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + server.TLS = applyTLSSettings(&tls.Config{}) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + conn, err := tls.Dial("tcp", strings.TrimPrefix(server.URL, "https://"), &tls.Config{InsecureSkipVerify: true}) + require.NoError(t, err) + defer conn.Close() + assert.Equal(t, uint16(tls.VersionTLS13), conn.ConnectionState().Version) +} + +// TestCurveStringMapCoversGoCurves fails when Go gains a curve the map does not name, so the +// config vocabulary cannot silently go stale the way it did for the post-quantum key exchanges +func TestCurveStringMapCoversGoCurves(t *testing.T) { + for id := range math.MaxUint16 + 1 { + name := tls.CurveID(id).String() + if strings.HasPrefix(name, "CurveID(") { + continue // crypto/tls has no name for this ID, so it supports no such curve + } + assert.Contains(t, curveStringMap, strings.TrimPrefix(strings.ToLower(name), "curve"), "curve %s", name) + } +} + +// TestCipherStringMapCoversLegacyNames pins the names accepted before the map was derived +// from crypto/tls, so no existing SSL_CIPHER_SUITES config silently stops resolving +func TestCipherStringMapCoversLegacyNames(t *testing.T) { + for _, name := range []string{ + "rsa_with_rc4_128_sha", "rsa_with_3des_ede_cbc_sha", "rsa_with_aes_128_cbc_sha", + "rsa_with_aes_256_cbc_sha", "rsa_with_aes_128_cbc_sha256", "rsa_with_aes_128_gcm_sha256", + "rsa_with_aes_256_gcm_sha384", "ecdhe_ecdsa_with_rc4_128_sha", "ecdhe_ecdsa_with_aes_128_cbc_sha", + "ecdhe_ecdsa_with_aes_256_cbc_sha", "ecdhe_rsa_with_rc4_128_sha", "ecdhe_rsa_with_3des_ede_cbc_sha", + "ecdhe_rsa_with_aes_128_cbc_sha", "ecdhe_rsa_with_aes_256_cbc_sha", "ecdhe_ecdsa_with_aes_128_cbc_sha256", + "ecdhe_rsa_with_aes_128_cbc_sha256", "ecdhe_rsa_with_aes_128_gcm_sha256", "ecdhe_ecdsa_with_aes_128_gcm_sha256", + "ecdhe_rsa_with_aes_256_gcm_sha384", "ecdhe_ecdsa_with_aes_256_gcm_sha384", + "ecdhe_rsa_with_chacha20_poly1305_sha256", "ecdhe_ecdsa_with_chacha20_poly1305_sha256", + "ecdhe_rsa_with_chacha20_poly1305", "ecdhe_ecdsa_with_chacha20_poly1305", + "aes_128_gcm_sha256", "aes_256_gcm_sha384", "chacha20_poly1305_sha256", + } { + assert.NotZero(t, cipherStringMap[name], "cipher %q", name) + } +} diff --git a/contrib/README b/contrib/README new file mode 100644 index 0000000..fddd5ac --- /dev/null +++ b/contrib/README @@ -0,0 +1 @@ +All files in subdirectories are templates, do modifications based on your environment first. \ No newline at end of file diff --git a/contrib/development/README.md b/contrib/development/README.md new file mode 100644 index 0000000..5fc5dd3 --- /dev/null +++ b/contrib/development/README.md @@ -0,0 +1,14 @@ +# IDE and code editor configuration + +## Table of Contents + +- [IDE and code editor configuration](#ide-and-code-editor-configuration) + - [Microsoft Visual Studio Code](#microsoft-visual-studio-code) + +## Microsoft Visual Studio Code + +Download Microsoft Visual Studio Code at https://code.visualstudio.com/ and follow instructions at https://code.visualstudio.com/docs/languages/go to setup Go extension for it. + +Create new directory `.vscode` in Gitea root folder and copy contents of folder [contrib/development/vscode](vscode/) to it. You can now use `Ctrl`+`Shift`+`B` to build gitea executable and `F5` to run it in debug mode. + +Supported on Debian, Ubuntu, Red Hat, Fedora, SUSE Linux, MacOS and Microsoft Windows. diff --git a/contrib/development/vscode/launch.json b/contrib/development/vscode/launch.json new file mode 100644 index 0000000..048428f --- /dev/null +++ b/contrib/development/vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch", + "type": "go", + "request": "launch", + "mode": "debug", + "buildFlags": "", + "program": "${workspaceRoot}/main.go", + "env": { + "GITEA_WORK_DIR": "${workspaceRoot}", + }, + "args": ["web"], + "showLog": true + } + ] +} diff --git a/contrib/development/vscode/settings.json b/contrib/development/vscode/settings.json new file mode 100644 index 0000000..d968e4d --- /dev/null +++ b/contrib/development/vscode/settings.json @@ -0,0 +1,4 @@ +{ + "go.buildTags": "", + "go.testFlags": ["-v"] +} diff --git a/contrib/development/vscode/tasks.json b/contrib/development/vscode/tasks.json new file mode 100644 index 0000000..490e303 --- /dev/null +++ b/contrib/development/vscode/tasks.json @@ -0,0 +1,27 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build", + "type": "shell", + "command": "go", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "linux": { + "args": ["build", "-o", "gitea", "${workspaceRoot}/main.go" ] + }, + "osx": { + "args": ["build", "-o", "gitea", "${workspaceRoot}/main.go" ] + }, + "windows": { + "args": ["build", "-o", "gitea.exe", "\"${workspaceRoot}\\main.go\""] + }, + "problemMatcher": ["$go"] + } + ] +} diff --git a/contrib/fhs-compliant-script/gitea b/contrib/fhs-compliant-script/gitea new file mode 100755 index 0000000..ea033b0 --- /dev/null +++ b/contrib/fhs-compliant-script/gitea @@ -0,0 +1,40 @@ +#!/bin/bash + +############################################################################# +# This script sets some defaults for gitea to run in a FHS compliant manner # +############################################################################# + +# It assumes that you place this script as gitea in /usr/bin +# +# And place the original in /usr/lib/gitea with working files in /var/lib/gitea +# and main configuration in /etc/gitea/app.ini +GITEA="/usr/lib/gitea/gitea" +WORK_DIR="/var/lib/gitea" +APP_INI="/etc/gitea/app.ini" + +APP_INI_SET="" +for i in "$@"; do + case "$i" in + "-c") + APP_INI_SET=1 + ;; + "-c="*) + APP_INI_SET=1 + ;; + "--config") + APP_INI_SET=1 + ;; + "--config="*) + APP_INI_SET=1 + ;; + *) + ;; + esac +done + +if [ -z "$APP_INI_SET" ]; then + CONF_ARG=("-c" "${GITEA_APP_INI:-$APP_INI}") +fi + +# Provide FHS compliant defaults +GITEA_WORK_DIR="${GITEA_WORK_DIR:-$WORK_DIR}" exec -a "$0" "$GITEA" "${CONF_ARG[@]}" "$@" diff --git a/contrib/grafana-monitoring-mixin/.gitignore b/contrib/grafana-monitoring-mixin/.gitignore new file mode 100644 index 0000000..f8472b0 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/.gitignore @@ -0,0 +1,2 @@ +dashboards_out +vendor diff --git a/contrib/grafana-monitoring-mixin/Makefile b/contrib/grafana-monitoring-mixin/Makefile new file mode 100644 index 0000000..429dfc4 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/Makefile @@ -0,0 +1,31 @@ +JSONNET_FMT := jsonnetfmt -n 2 --max-blank-lines 1 --string-style s --comment-style s + +.PHONY: all +all: build dashboards_out + +vendor: jsonnetfile.json + jb install + +.PHONY: build +build: vendor + +.PHONY: fmt +fmt: + find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + xargs -n 1 -- $(JSONNET_FMT) -i + +.PHONY: lint +lint: build + find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ + while read f; do \ + $(JSONNET_FMT) "$$f" | diff -u "$$f" -; \ + done + mixtool lint mixin.libsonnet + +dashboards_out: mixin.libsonnet config.libsonnet $(wildcard dashboards/*) + @mkdir -p dashboards_out + jsonnet -J vendor -m dashboards_out lib/dashboards.jsonnet + +.PHONY: clean +clean: + rm -rf dashboards_out diff --git a/contrib/grafana-monitoring-mixin/README.md b/contrib/grafana-monitoring-mixin/README.md new file mode 100644 index 0000000..2e11706 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/README.md @@ -0,0 +1,33 @@ +# Gitea Mixin + +Gitea Mixin is a set of configurable Grafana dashboards based on the metrics exported by the Gitea built-in metrics endpoint. + +## Generate config files + +You can manually generate dashboards, but first you should install some tools: + +```bash +go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb@latest +go install github.com/google/go-jsonnet/cmd/jsonnet@latest +# or in brew: brew install go-jsonnet +``` + +For linting and formatting, you would also need `mixtool` and `jsonnetfmt` installed. If you +have a working Go development environment, it's easiest to run the following: + +```bash +go install github.com/monitoring-mixins/mixtool/cmd/mixtool@latest +go install github.com/google/go-jsonnet/cmd/jsonnetfmt@latest +``` + +The files in `dashboards_out` need to be imported +into your Grafana server. The exact details will be depending on your environment. + +Edit `config.libsonnet` if required and then build JSON dashboard files for Grafana: + +```bash +make +``` + +For more advanced uses of mixins, see +https://github.com/monitoring-mixins/docs. diff --git a/contrib/grafana-monitoring-mixin/config.libsonnet b/contrib/grafana-monitoring-mixin/config.libsonnet new file mode 100644 index 0000000..446fc09 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/config.libsonnet @@ -0,0 +1,99 @@ +{ + _config+:: { + local c = self, + dashboardNamePrefix: 'Gitea', + dashboardTags: ['gitea'], + dashboardPeriod: 'now-1h', + dashboardTimezone: 'default', + dashboardRefresh: '1m', + + // please see https://docs.gitea.com/administration/config-cheat-sheet#metrics-metrics + // Show issue by repository metrics with format gitea_issues_by_repository{repository="org/repo"} 5. + // Requires Gitea 1.16.0 with ENABLED_ISSUE_BY_REPOSITORY set to true. + showIssuesByRepository: true, + // Show graphs for issue by label metrics with format gitea_issues_by_label{label="bug"} 2. + // Requires Gitea 1.16.0 with ENABLED_ISSUE_BY_LABEL set to true. + showIssuesByLabel: true, + + // Requires Gitea 1.16.0. + showIssuesOpenClose: true, + + // add or remove metrics from dashboard + giteaStatMetrics: + [ + { + name: 'gitea_organizations', + description: 'Organizations', + }, + { + name: 'gitea_teams', + description: 'Teams', + }, + { + name: 'gitea_users', + description: 'Users', + }, + { + name: 'gitea_repositories', + description: 'Repositories', + }, + { + name: 'gitea_milestones', + description: 'Milestones', + }, + { + name: 'gitea_stars', + description: 'Stars', + }, + { + name: 'gitea_releases', + description: 'Releases', + }, + ] + + + if c.showIssuesOpenClose then + [ + { + name: 'gitea_issues_open', + description: 'Issues opened', + }, + { + name: 'gitea_issues_closed', + description: 'Issues closed', + }, + ] else + [ + { + name: 'gitea_issues', + description: 'Issues', + }, + ], + //set this for using label colors on graphs + issueLabels: [ + { + label: 'bug', + color: '#ee0701', + }, + { + label: 'duplicate', + color: '#cccccc', + }, + { + label: 'invalid', + color: '#e6e6e6', + }, + { + label: 'enhancement', + color: '#84b6eb', + }, + { + label: 'help wanted', + color: '#128a0c', + }, + { + label: 'question', + color: '#cc317c', + }, + ], + }, +} diff --git a/contrib/grafana-monitoring-mixin/dashboards/dashboards.libsonnet b/contrib/grafana-monitoring-mixin/dashboards/dashboards.libsonnet new file mode 100644 index 0000000..800feec --- /dev/null +++ b/contrib/grafana-monitoring-mixin/dashboards/dashboards.libsonnet @@ -0,0 +1 @@ +(import 'overview.libsonnet') diff --git a/contrib/grafana-monitoring-mixin/dashboards/overview.libsonnet b/contrib/grafana-monitoring-mixin/dashboards/overview.libsonnet new file mode 100644 index 0000000..31b7d4f --- /dev/null +++ b/contrib/grafana-monitoring-mixin/dashboards/overview.libsonnet @@ -0,0 +1,467 @@ +local grafana = import 'github.com/grafana/grafonnet-lib/grafonnet/grafana.libsonnet'; +local prometheus = grafana.prometheus; + +local addIssueLabelsOverrides(labels) = + { + fieldConfig+: { + overrides+: [ + { + matcher: { + id: 'byRegexp', + options: label.label, + }, + properties: [ + { + id: 'color', + value: { + fixedColor: label.color, + mode: 'fixed', + }, + }, + ], + } + for label in labels + ], + }, + }; + +{ + + grafanaDashboards+:: { + + local giteaSelector = 'job=~"$job", instance=~"$instance"', + local giteaStatsPanel = + grafana.statPanel.new( + 'Gitea stats', + datasource='$datasource', + reducerFunction='lastNotNull', + graphMode='none', + colorMode='value', + ) + .addTargets( + [ + prometheus.target(expr='%s{%s}' % [metric.name, giteaSelector], legendFormat=metric.description, intervalFactor=10) + for metric in $._config.giteaStatMetrics + ] + ) + + { + fieldConfig+: { + defaults+: { + color: { + fixedColor: 'blue', + mode: 'fixed', + }, + }, + }, + }, + + local giteaUptimePanel = + grafana.statPanel.new( + 'Uptime', + datasource='$datasource', + reducerFunction='last', + graphMode='area', + colorMode='value', + ) + .addTarget(prometheus.target(expr='time()-process_start_time_seconds{%s}' % giteaSelector, intervalFactor=1)) + + { + fieldConfig+: { + defaults+: { + color: { + fixedColor: 'blue', + mode: 'fixed', + }, + unit: 's', + }, + }, + }, + + local giteaMemoryPanel = + grafana.graphPanel.new( + 'Memory usage', + datasource='$datasource' + ) + .addTarget(prometheus.target(expr='process_resident_memory_bytes{%s}' % giteaSelector, intervalFactor=2)) + + { + type: 'timeseries', + options+: { + tooltip: { + mode: 'multi', + }, + legend+: { + displayMode: 'hidden', + }, + }, + fieldConfig+: { + defaults+: { + custom+: { + lineInterpolation: 'smooth', + fillOpacity: 15, + }, + color: { + fixedColor: 'green', + mode: 'fixed', + }, + unit: 'decbytes', + }, + }, + }, + + local giteaCpuPanel = + grafana.graphPanel.new( + 'CPU usage', + datasource='$datasource' + ) + .addTarget(prometheus.target(expr='rate(process_cpu_seconds_total{%s}[$__rate_interval])*100' % giteaSelector, intervalFactor=2)) + + { + type: 'timeseries', + options+: { + tooltip: { + mode: 'multi', + }, + legend+: { + displayMode: 'hidden', + }, + }, + fieldConfig+: { + defaults+: { + custom+: { + lineInterpolation: 'smooth', + gradientMode: 'scheme', + fillOpacity: 15, + axisSoftMin: 0, + axisSoftMax: 0, + }, + color: { + mode: 'continuous-GrYlRd', // from green to red (100%) + }, + unit: 'percent', + }, + overrides: [ + { + matcher: { + id: 'byRegexp', + options: '.+', + }, + properties: [ + { + id: 'max', + value: 100, + }, + { + id: 'min', + value: 0, + }, + ], + }, + ], + }, + }, + + local giteaFileDescriptorsPanel = + grafana.graphPanel.new( + 'File descriptors usage', + datasource='$datasource', + ) + .addTarget(prometheus.target(expr='process_open_fds{%s}' % giteaSelector, intervalFactor=2)) + .addTarget(prometheus.target(expr='process_max_fds{%s}' % giteaSelector, intervalFactor=2)) + .addSeriesOverride( + { + alias: '/process_max_fds.+/', + color: '#F2495C', // red + dashes: true, + fill: 0, + }, + ) + + { + type: 'timeseries', + options+: { + tooltip: { + mode: 'multi', + }, + legend+: { + displayMode: 'hidden', + }, + }, + fieldConfig+: { + defaults+: { + custom+: { + lineInterpolation: 'smooth', + gradientMode: 'scheme', + fillOpacity: 0, + }, + color: { + fixedColor: 'green', + mode: 'fixed', + }, + unit: '', + }, + overrides: [ + { + matcher: { + id: 'byFrameRefID', + options: 'B', + }, + properties: [ + { + id: 'custom.lineStyle', + value: { + fill: 'dash', + dash: [ + 10, + 10, + ], + }, + }, + { + id: 'color', + value: { + mode: 'fixed', + fixedColor: 'red', + }, + }, + ], + }, + ], + }, + }, + + local giteaChangesPanelPrototype = + grafana.graphPanel.new( + '', + datasource='$datasource', + interval='$agg_interval', + maxDataPoints=10000, + ) + + { + type: 'timeseries', + options+: { + tooltip: { + mode: 'multi', + }, + legend+: { + calcs+: [ + 'sum', + ], + }, + }, + fieldConfig+: { + defaults+: { + noValue: '0', + custom+: { + drawStyle: 'bars', + barAlignment: -1, + fillOpacity: 50, + gradientMode: 'hue', + pointSize: 1, + lineWidth: 0, + stacking: { + group: 'A', + mode: 'normal', + }, + }, + }, + }, + }, + + local giteaChangesPanelAll = + giteaChangesPanelPrototype + .addTarget(prometheus.target(expr='changes(process_start_time_seconds{%s}[$__interval]) > 0' % [giteaSelector], legendFormat='Restarts', intervalFactor=1)) + .addTargets( + [ + prometheus.target(expr='floor(delta(%s{%s}[$__interval])) > 0' % [metric.name, giteaSelector], legendFormat=metric.description, intervalFactor=1) + for metric in $._config.giteaStatMetrics + ] + ) + { id: 200 }, // some unique number, beyond the maximum number of panels in the dashboard, + + local giteaChangesPanelTotal = + grafana.statPanel.new( + 'Changes', + datasource='-- Dashboard --', + reducerFunction='sum', + graphMode='none', + textMode='value_and_name', + colorMode='value', + ) + + { + targets+: [ + { + panelId: giteaChangesPanelAll.id, + refId: 'A', + }, + ], + } + + { + fieldConfig+: { + defaults+: { + color: { + mode: 'palette-classic', + }, + }, + }, + }, + + local giteaChangesByRepositories = + giteaChangesPanelPrototype + .addTarget(prometheus.target(expr='floor(increase(gitea_issues_by_repository{%s}[$__interval])) > 0' % [giteaSelector], legendFormat='{{ repository }}', intervalFactor=1)) + + { id: 210 }, // some unique number, beyond the maximum number of panels in the dashboard, + + local giteaChangesByRepositoriesTotal = + grafana.statPanel.new( + 'Issues by repository', + datasource='-- Dashboard --', + reducerFunction='sum', + graphMode='none', + textMode='value_and_name', + colorMode='value', + ) + + { + id: 211, + targets+: [ + { + panelId: giteaChangesByRepositories.id, + refId: 'A', + }, + ], + } + + { + fieldConfig+: { + defaults+: { + color: { + mode: 'palette-classic', + }, + }, + }, + }, + + local giteaChangesByLabel = + giteaChangesPanelPrototype + .addTarget(prometheus.target(expr='floor(increase(gitea_issues_by_label{%s}[$__interval])) > 0' % [giteaSelector], legendFormat='{{ label }}', intervalFactor=1)) + + addIssueLabelsOverrides($._config.issueLabels) + + { id: 220 }, // some unique number, beyond the maximum number of panels in the dashboard, + + local giteaChangesByLabelTotal = + grafana.statPanel.new( + 'Issues by labels', + datasource='-- Dashboard --', + reducerFunction='sum', + graphMode='none', + textMode='value_and_name', + colorMode='value', + ) + + addIssueLabelsOverrides($._config.issueLabels) + + { + id: 221, + targets+: [ + { + panelId: giteaChangesByLabel.id, + refId: 'A', + }, + ], + } + + { + fieldConfig+: { + defaults+: { + color: { + mode: 'palette-classic', + }, + }, + }, + }, + + 'gitea-overview.json': + grafana.dashboard.new( + '%s Overview' % $._config.dashboardNamePrefix, + time_from='%s' % $._config.dashboardPeriod, + editable=false, + tags=($._config.dashboardTags), + timezone='%s' % $._config.dashboardTimezone, + refresh='%s' % $._config.dashboardRefresh, + graphTooltip='shared_crosshair', + uid='gitea-overview' + ) + .addTemplate( + { + current: { + text: 'Prometheus', + value: 'Prometheus', + }, + hide: 0, + label: 'Data Source', + name: 'datasource', + options: [], + query: 'prometheus', + refresh: 1, + regex: '', + type: 'datasource', + }, + ) + .addTemplate( + { + hide: 0, + label: 'job', + name: 'job', + options: [], + datasource: '$datasource', + query: 'label_values(gitea_organizations, job)', + refresh: 1, + regex: '', + type: 'query', + multi: true, + allValue: '.+' + }, + ) + .addTemplate( + { + hide: 0, + label: 'instance', + name: 'instance', + options: [], + datasource: '$datasource', + query: 'label_values(gitea_organizations{job="$job"}, instance)', + refresh: 1, + regex: '', + type: 'query', + multi: true, + allValue: '.+' + }, + ) + .addTemplate( + { + hide: 0, + label: 'aggregation interval', + name: 'agg_interval', + auto_min: '1m', + auto: true, + query: '1m,10m,1h,1d,7d', + type: 'interval', + }, + ) + .addPanel(grafana.row.new(title='General'), gridPos={ x: 0, y: 0, w: 0, h: 0 },) + .addPanel(giteaStatsPanel, gridPos={ x: 0, y: 0, w: 16, h: 4 }) + .addPanel(giteaUptimePanel, gridPos={ x: 16, y: 0, w: 8, h: 4 }) + .addPanel(giteaMemoryPanel, gridPos={ x: 0, y: 4, w: 8, h: 6 }) + .addPanel(giteaCpuPanel, gridPos={ x: 8, y: 4, w: 8, h: 6 }) + .addPanel(giteaFileDescriptorsPanel, gridPos={ x: 16, y: 4, w: 8, h: 6 }) + .addPanel(grafana.row.new(title='Changes', collapse=false), gridPos={ x: 0, y: 10, w: 24, h: 8 }) + .addPanel(giteaChangesPanelTotal, gridPos={ x: 0, y: 12, w: 6, h: 8 }) + + // use patching instead of .addPanel() to keep static ids + { + panels+: std.flattenArrays([ + [ + giteaChangesPanelAll { gridPos: { x: 6, y: 12, w: 18, h: 8 } }, + ], + if $._config.showIssuesByRepository then + [ + giteaChangesByRepositoriesTotal { gridPos: { x: 0, y: 20, w: 6, h: 8 } }, + giteaChangesByRepositories { gridPos: { x: 6, y: 20, w: 18, h: 8 } }, + ] else [], + if $._config.showIssuesByLabel then + [ + giteaChangesByLabelTotal { gridPos: { x: 0, y: 28, w: 6, h: 8 } }, + giteaChangesByLabel { gridPos: { x: 6, y: 28, w: 18, h: 8 } }, + ] else [], + ]), + }, + }, +} diff --git a/contrib/grafana-monitoring-mixin/jsonnetfile.json b/contrib/grafana-monitoring-mixin/jsonnetfile.json new file mode 100644 index 0000000..5e9bae2 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/jsonnetfile.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "dependencies": [ + { + "source": { + "git": { + "remote": "https://github.com/grafana/grafonnet-lib.git", + "subdir": "grafonnet" + } + }, + "version": "master" + } + ], + "legacyImports": false + } \ No newline at end of file diff --git a/contrib/grafana-monitoring-mixin/jsonnetfile.lock.json b/contrib/grafana-monitoring-mixin/jsonnetfile.lock.json new file mode 100644 index 0000000..0430b39 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/jsonnetfile.lock.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "dependencies": [ + { + "source": { + "git": { + "remote": "https://github.com/grafana/grafonnet-lib.git", + "subdir": "grafonnet" + } + }, + "version": "3626fc4dc2326931c530861ac5bebe39444f6cbf", + "sum": "gF8foHByYcB25jcUOBqP6jxk0OPifQMjPvKY0HaCk6w=" + } + ], + "legacyImports": false +} diff --git a/contrib/grafana-monitoring-mixin/lib/alerts.jsonnet b/contrib/grafana-monitoring-mixin/lib/alerts.jsonnet new file mode 100644 index 0000000..d396a38 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/lib/alerts.jsonnet @@ -0,0 +1 @@ +std.manifestYamlDoc((import '../mixin.libsonnet').prometheusAlerts) diff --git a/contrib/grafana-monitoring-mixin/lib/dashboards.jsonnet b/contrib/grafana-monitoring-mixin/lib/dashboards.jsonnet new file mode 100644 index 0000000..dadaebe --- /dev/null +++ b/contrib/grafana-monitoring-mixin/lib/dashboards.jsonnet @@ -0,0 +1,6 @@ +local dashboards = (import '../mixin.libsonnet').grafanaDashboards; + +{ + [name]: dashboards[name] + for name in std.objectFields(dashboards) +} diff --git a/contrib/grafana-monitoring-mixin/lib/rules.jsonnet b/contrib/grafana-monitoring-mixin/lib/rules.jsonnet new file mode 100644 index 0000000..2d7fa91 --- /dev/null +++ b/contrib/grafana-monitoring-mixin/lib/rules.jsonnet @@ -0,0 +1 @@ +std.manifestYamlDoc((import '../mixin.libsonnet').prometheusRules) diff --git a/contrib/grafana-monitoring-mixin/mixin.libsonnet b/contrib/grafana-monitoring-mixin/mixin.libsonnet new file mode 100644 index 0000000..bb56a6c --- /dev/null +++ b/contrib/grafana-monitoring-mixin/mixin.libsonnet @@ -0,0 +1,2 @@ +(import 'dashboards/dashboards.libsonnet') + +(import 'config.libsonnet') diff --git a/contrib/options/label/Advanced b/contrib/options/label/Advanced new file mode 100644 index 0000000..e413236 --- /dev/null +++ b/contrib/options/label/Advanced @@ -0,0 +1,26 @@ +#424242 Status: Needs feedback ; Feedback is needed +#fbc02d Status: In progress ; Work is in progress +#43a047 Status: Completed ; Work is complete +#00796b Status: Help wanted ; +#880e4f Status: Blocked ; +#b0bec5 Status: Stale ; + +#d32f2f Kind: Bug ; +#607d8b Kind: Question ; +#9c27b0 Kind: Security ; +#795548 Kind: Testing ; +#c62828 Kind: Breaking ; +#37474f Kind: Documentation ; +#0288d1 Kind: Feature ; +#2e7d32 Kind: Enhancement ; +#f4511e Kind: Maintenance ; + +#546e7a Reviewed: Invalid ; Something was marked as invalid +#616161 Reviewed: Duplicate; Something exists already +#795548 Reviewed: Confirmed ; Something has been confirmed +#eeeeee Reviewed: Wontfix ; Something won't be fixed + +#d32f2f Priority: High ; The priority is high +#e64a19 Priority: Medium ; The priority is medium +#4caf50 Priority: Low ; The priority is low +#b71c1c Priority: Critical ; The priority is critical diff --git a/contrib/sample-page/privacy.html.sample b/contrib/sample-page/privacy.html.sample new file mode 100644 index 0000000..adb3ea7 --- /dev/null +++ b/contrib/sample-page/privacy.html.sample @@ -0,0 +1,196 @@ + + + + Privacy Policy + + + +

Privacy Policy

+ +

Last updated: January 29, 2020

+ +

Who We Are?

+ +

Your Gitea Instance

+ +

What Personal Data We Collect?

+ +

We collect following personal data (collectively called User Personal Information):

+ +
    +
  1. Registration information (username, email, password, etc.)
  2. +
  3. Profile information for your Account (such as your full name, biography, website, gpg key, and location.)
  4. +
  5. Usage information (pages you view, your IP address, referring site, session information, and request date and time.)
  6. +
  7. Device information (its IP address, client application information, language preference, operating system and application version, device type, ID, model and manufacturer.)
  8. +
  9. Git data that you upload to a repository
  10. +
  11. Cookies and Similar Technologies
  12. +
+ +

We may also collect User Personal Information from third-parties (vendors, partners, or affiliates). We don't purchase them from third-party data brokers, though.

+ +

However, we don't intentionally collect sensitive information (such as racial or ethnic origin, political affiliations, religious/philosophical beliefs, biometric data, etc.)

+ + +

How We Share Information We Collect?

+ +

We may share your User Personal Information with third-parties under following circumstances:

+ +

With your Consent

+ +

We share your User Personal Information, if you consent, after letting you know what information will be shared, with whom, and why. For example, if you allow third party applications to access your Account using OAuth2 providers, we share all information associated with your Account, including private repos and organizations. You may also direct us through your action on Your Gitea Instance to share your User Personal Information, such as when joining an Organization.

+ +

With Service Providers

+ +

We share User Personal Information with a limited number of service providers who process it on our behalf to provide or improve our Service, and who have agreed to privacy restrictions similar to the ones in our Privacy Statement by signing data protection agreements or making similar commitments. Our service providers perform payment processing, customer support ticketing, network data transmission, security, and other similar services. While Your Gitea Instance processes all User Personal Information in the (country/state where Gitea is deployed), our service providers may process data outside of (country/state where Gitea is deployed), the United States or the European Union.

+ +

For Security Purposes

+ +

If you are a member of an Organization, Your Gitea Instance may share your username, Usage Information, and Device Information associated with that Organization with an owner and/or administrator of the Organization who has agreed to the Corporate Terms of Service or applicable customer agreements, to the extent that such information is provided only to investigate or respond to a security incident that affects or compromises the security of that particular Organization.

+ +

For Legal Disclosure

+ +

Your Gitea Instance strives for transparency in complying with legal process and legal obligations. Unless prevented from doing so by law or court order, or in rare, exigent circumstances, we make a reasonable effort to notify users of any legally compelled or required disclosure of their information. Your Gitea Instance may disclose User Personal Information or other information we collect about you to law enforcement if required in response to a valid subpoena, court order, search warrant, a similar government order, or when we believe in good faith that disclosure is necessary to comply with our legal obligations, to protect our property or rights, or those of third parties or the public at large.

+ +

Change in Control or Sale

+ +

We may share User Personal Information if we are involved in a merger, sale, or acquisition of corporate entities or business units. If any such change of ownership happens, we will ensure that it is under terms that preserve the confidentiality of User Personal Information, and we will notify you on our Website or by email before any transfer of your User Personal Information. The organization receiving any User Personal Information will have to honor any promises we made in our Privacy Statement or Terms of Service.

+ +

Aggregate, Non-Personally Identifying Information

+ +

We share certain aggregated, non-personally identifying information with others about how our users, collectively, use Your Gitea Instance, or how our users respond to our other offerings, such as our conferences or events. For example, we may compile statistics on the open source activity across Your Gitea Instance.

+ +

We don't sell your User Personal Information for monetary or other consideration.

+ +

How We Use Your Information?

+ +

We may use your information for following purposes:

+ +
    +
  1. We use your Registration Information to create your account, and to provide you the Service.
  2. +
  3. We use your User Personal Information, specifically your username, to identify you on Your Gitea Instance.
  4. +
  5. We use your Profile Information to fill out your Account profile and to share that profile with other users if you ask us to.
  6. +
  7. We use your email address to communicate with you, if you've said that's okay, and only for the reasons you’ve said that’s okay.
  8. +
  9. We use User Personal Information and other data to make recommendations for you, such as to suggest projects you may want to follow or contribute to. We learn from your public behavior on Your Gitea Instance—such as the projects you star—to determine your coding interests, and we recommend similar projects. These recommendations are automated decisions, but they have no legal impact on your rights.
  10. +
  11. We use Usage Information and Device Information to better understand how our Users use Your Gitea Instance and to improve our Website and Service.
  12. +
  13. We may use your User Personal Information if it is necessary for security purposes or to investigate possible fraud or attempts to harm Your Gitea Instance or our Users.
  14. +
  15. We may use your User Personal Information to comply with our legal obligations, protect our intellectual property, and enforce our Terms of Service.
  16. + +
  17. We limit our use of your User Personal Information to the purposes listed in this Privacy Statement. If we need to use your User Personal Information for other purposes, we will ask your permission first. You can always see what information we have, how we're using it, and what permissions you have given us in your user profile.
  18. +
+ +

How Your Gitea Instance Secures Your Information?

+ +

Your Gitea Instance takes all measures reasonably necessary to protect User Personal Information from unauthorized access, alteration, or destruction; maintain data accuracy; and help ensure the appropriate use of User Personal Information.

+ +

To the extent above, we enforce a written security information program, which:

+ +
    +
  • aligns with industry recognized frameworks;
  • +
  • includes security safeguards reasonably designed to protect the confidentiality, integrity, availability, and resilience of our Users' data;
  • +
  • is appropriate to the nature, size, and complexity of Your Gitea Instance’s business operations;
  • +
  • includes incident response and data breach notification processes; and
  • +
  • complies with applicable information security-related laws and regulations in the geographic regions where Your Gitea Instance does business.
  • +
+ +

In the event of a data breach that affects your User Personal Information, we will act promptly to mitigate the impact of a breach and notify any affected Users without undue delay.

+ +

Transmission of data on Your Gitea Instance is encrypted using SSH, HTTPS (TLS), and git repository content is encrypted at rest. We host Your Gitea Instance at our hosting partner, which they provide data centers with high level of physical and network security.

+ +

Disclaimer: No method of transmission, or method of electronic storage, is 100% secure, therefore, we cannot guarantee absolute security.

+ +

Cookies and Tracking Usage

+ +

Cookies

+ +

We uses cookies to make interactions with our service easy and meaningful. Cookies are small text files that websites often store on computer hard drives or mobile devices of visitors. We use cookies (and similar technologies, like HTML5 localStorage) to keep you logged in, remember your preferences, and provide information for future development of Your Gitea Instance. For security purposes, we use cookies to identify a device. By using our Website, you agree that we can place these types of cookies on your computer or device. If you disable your browser or device’s ability to accept these cookies, you will not be able to log in or use our services.

+ +

Tracking and Analytics

+ +

Out of the box, Gitea doesn't use third-party analytics. In case when we opt in to their usage, we do that to help us evaluate our Users' use of Your Gitea Instance, compile statistical reports on activity, and improve our content and Website performance. We only use these third-party analytics providers on certain areas of our Website, and all of them have signed data protection agreements with us that limit the type of User Personal Information they can collect and the purpose for which they can process the information. In addition, we may also deploy internal analytics software to provide similar functionality.

+ +

Some browsers have incorporated "Do Not Track" (DNT) features that can send a signal to the websites you visit indicating you do not wish to be tracked. Your Gitea Instance responds to browser DNT signals and follows the W3C standard for responding to DNT signals. If you have not enabled DNT on a browser that supports it, cookies on some parts of our Website will track your online browsing activity on other online services over time, though we do not permit third parties other than our analytics and service providers to track Your Gitea Instance Users' activity over time on Your Gitea Instance.

+ +

Repository Contents

+ +

Our employees do not access private repositories unless required to for security purposes, for support, to maintain integrity of the Service, or to comply with our legal obligations. While we don't generally search for content in your repositories, we may scan our servers and your content to detect tokens or security signatures, known malwares, or child exploitation imagery.

+ +

If your repository is public, anyone may view its contents. If you include private, confidential or Sensitive Personal Information, such as email addresses or passwords, in your public repository, that information may be indexed by search engines or used by third parties.

+ +

Public Information

+ +

Many of our services and feature are public-facing. If your content is public-facing, third parties may access and use it in compliance with our Terms of Service, such as by viewing your profile or repositories or pulling data via our API. We do not sell that content; it is yours. However, we do allow third parties, such as research organizations or archives, to compile public-facing Your Gitea Instance information. Other third parties, such as data brokers, have been known to scrape Your Gitea Instance and compile data as well.

+ +

Your User Personal Information associated with your content could be gathered by third parties in these compilations of Your Gitea Instance data. If you do not want your User Personal Information to appear in third parties’ compilations of Your Gitea Instance data, please do not make your User Personal Information publicly available and be sure to configure your email address to be private in your user profile and in your git commit settings.

+ +

If you would like to compile Your Gitea Instance data, you must comply with our Terms of Service regarding scraping and privacy, and you may only use any public-facing User Personal Information you gather for the purpose for which our user authorized it. For example, where a Your Gitea Instance user has made an email address public-facing for the purpose of identification and attribution, do not use that email address for commercial advertising. We expect you to reasonably secure any User Personal Information you have gathered from Your Gitea Instance, and to respond promptly to complaints, removal requests, and "do not contact" requests from Your Gitea Instance or Your Gitea Instance users.

+ +

In similar fashion, projects on Your Gitea Instance may include publicly available User Personal Information collected as part of the collaborative events.

+ +

Organizations

+ +

If you collaborate on or become a member of an Organization, then its Account owners may receive your User Personal Information. When you accept an invitation to an Organization, you will be notified of the types of information owners may be able to see. If you accept an invitation to an Organization with a verified domain, then the owners of that Organization will be able to see your full email address(es) within that Organization's verified domain(s).

+ +

Please note, Your Gitea Instance may share your username, Usage Information, and Device Information with the owner of the Organization you are a member of, to the extent that your User Personal Information is provided only to investigate or respond to a security incident that affects or compromises the security of that particular Organization.

+ +

If you collaborate with or become a member of an Account that has agreed to a Data Protection Addendum (DPA) to this Privacy Policy, then that DPA governs in the event of conflicts between this Privacy Policy and DPA with respect to your activity in the Account.

+ +

Please contact the Account owners for more information about how they might process your User Personal Information in their Organization and the ways for you to access, update, alter, or delete the User Personal Information stored in the Account.

+ +

How You Can Access and Control the Information We Collect?

+ +

If you're already a Your Gitea Instance user, you may access, update, alter, or delete your basic user information by editing your user profile. You can control the information we collect about you by limiting what information is in your profile, or by keeping your information current.

+ +

If Your Gitea Instance processes information about you, such as information receives from third parties, and you do not have an account, then you may, subject to applicable law, access, update, alter, delete, or object to the processing of your personal information by contacting our support.

+ +

Data Portability

+ +

As a Your Gitea Instance User, you can always take your data with you. You can clone your repositories to your computer, or you can perform migrations using the provided interfaces, for example.

+ +

Data Retention and Deletion of Data

+ +

In general, Your Gitea Instance retains User Personal Information for as long as your account is active, or as needed to provide you service.

+ +

If you would like to cancel your account or delete your User Personal Information, you may do so in your user profile. We retain and use your information as necessary to comply with our legal obligations, resolve disputes, and enforce our agreements, but barring legal requirements, we will delete your full profile (within reason) within 90 days of your request. Feel free to contact our support to request erasure of the data we process on the basis of consent within 30 days.

+ +

After an account has been deleted, certain data, such as contributions to other Users' repositories and comments in others' issues, will remain. However, we will delete or de-identify your User Personal Information, including your username and email address, from the author field of issues, pull requests, and comments by associating them with a ghost user.

+ +

That said, the email address you have supplied via your Git commit settings will always be associated with your commits in the Git system. If you choose to make your email address private, you should also update your Git commit settings. We are unable to change or delete data in the Git commit history — the Git software is designed to maintain a record — but we do enable you to control what information you put in that record.

+ +

Our Global Privacy Practices

+ +

We store and process the information that we collect in the (country/state where Gitea is deployed) in accordance with this Privacy Statement though our service providers may store and process data outside the (country/state where Gitea is deployed). However, we understand that we have Users from different countries and regions with different privacy expectations, and we try to meet those needs even when the (country/state where Gitea is deployed) does not have the same privacy framework as other countries.

+ +

We provide a high standard of privacy protection—as described in this Privacy Statement—to all our users around the world, regardless of their country of origin or location, and we are proud of the levels of notice, choice, accountability, security, data integrity, access, and recourse we provide. We work hard to comply with the applicable data privacy laws wherever we do business, working with our Data Protection Officer as part of a cross-functional team that oversees our privacy compliance efforts. Additionally, if our vendors or affiliates have access to User Personal Information, they must sign agreements that require them to comply with our privacy policies and with applicable data privacy laws.

+ +

In particular:

+ +
    +
  • Your Gitea Instance provides clear methods of unambiguous, informed, specific, and freely given consent at the time of data collection, when we collect your User Personal Information using consent as a basis.
  • +
  • We collect only the minimum amount of User Personal Information necessary for our purposes, unless you choose to provide more. We encourage you to only give us the amount of data you are comfortable sharing.
  • +
  • We offer you simple methods of accessing, altering, or deleting the User Personal Information we have collected, where legally permitted.
  • +
  • We provide our Users notice, choice, accountability, security, and access regarding their User Personal Information, and we limit the purpose for processing it. We also provide our Users a method of recourse and enforcement. These are the Privacy Shield Principles, but they are also just good practices.
  • +
+ +

How We Communicate with You?

+ +

We use your email address to communicate with you, if you've said that's okay, and only for the reasons you’ve said that’s okay. For example, if you contact our support with a request, we respond to you via email. You have a lot of control over how your email address is used and shared on and through Your Gitea instance. You may manage your communication preferences in your user profile.

+ +

By design, the Git version control system associates many actions with a User's email address, such as commit messages. We are not able to change many aspects of the Git system. If you would like your email address to remain private, even when you’re commenting on public repositories, you can create a private email address in your user profile. You should also update your local Git configuration to use your private email address. This will not change how we contact you, but it will affect how others see you.

+ +

Depending on your email settings, Your Gitea instance may occasionally send notification emails about changes in a repository you’re watching, new features, requests for feedback, important policy changes, or to offer customer support. We also send marketing emails, based on your choices and in accordance with applicable laws and regulations. There's an “unsubscribe” link located at the bottom of each of the marketing emails we send you. Note that you can opt out of any communications with us, except the important ones (like from our support and system emails).

+ +

Our emails may contain a pixel tag, which is a small, clear image that can tell us whether or not you have opened an email and what your IP address is. We use this pixel tag to make our email more effective for you and to make sure we’re not sending you unwanted email.

+ +

Changes to this Privacy Policy

+ +

Although most changes are likely to be minor, Your Gitea Instance may change our Privacy Statement from time to time. We will provide notification to Users of material changes to this Privacy Statement through our Website at least 30 days prior to the change taking effect by posting a notice on our home page or sending email to the primary email address specified in your account.

+ +

Contact

+ +

If you have any concerns about privacy, please contact us at privacy@your-gitea-instance. We will respond promptly, within 45 days.

+ +

COPYING

+ +

This document is licensed under CC0 Public Domain License. See full legal code here.

+ + diff --git a/contrib/sample-page/tos.html.sample b/contrib/sample-page/tos.html.sample new file mode 100644 index 0000000..d390829 --- /dev/null +++ b/contrib/sample-page/tos.html.sample @@ -0,0 +1,245 @@ + + + + Terms of Service + + + +

Terms of Service

+ +

Last updated: January 29, 2020

+ +

Thank you for choosing Your Gitea Instance! Before you use it, please read this Terms of Service agreement carefully, which contains important contract between us and our users.

+ +

Definitions

+ +
    +
  1. An "Account" represents your legal relationship with Your Gitea Instance. A “User Account” represents an individual User’s authorization to log in to and use the Service and serves as a User’s identity on Your Gitea Instance. “Organizations” are shared workspaces that may be associated with a single entity or with one or more Users where multiple Users can collaborate across many projects at once. A User Account can be a member of any number of Organizations.
  2. + +
  3. The "Agreement" collectively refers to all terms, conditions, and notices referenced or contained in this document and other operating rules, policies (including Privacy Policy) and procedures that we may publish from time to time on this Website.
  4. + +
  5. “Content” refers to content featured or displayed through the Website, including without limitation code, text, data, articles, images, photographs, graphics, software, applications, packages, designs, features, and other materials that are available on the Website or otherwise available through the Service. "Content" also includes Services. “User-Generated Content” is Content, written or otherwise, created or uploaded by our Users. "Your Content" is Content that you create or own.
  6. + +
  7. "Your Gitea Instance", "We", and "Us" refers to Your Gitea Instance, as well as our affiliates, directors, subsidiaries, contractors, licensors, officers, agents, and employees.
  8. + +
  9. The "Service" refers to applications/software, products, and services provided by Your Gitea Instance.
  10. + +
  11. The "User", "You", and "Your" refers to individual person or institution (organizations or company) that has visited or using the Service; that have access or use any part of the Account; or that directs to use the Account to perform its function. Please note that additional terms may apply for Accounts related to business or government.
  12. + +
  13. The "Website" refers to Your Gitea Instance's website at your-gitea-instance, including its subdomains and other websites owned by Your Gitea Instance.
  14. +
+ +

Account Terms

+ +

Account Controls

+ +
    +
  • Users: Subject to these Terms, you retain ultimate administrative control over your User Account and the Content within it.
  • + +
  • Organizations. The "owner" of an Organization that was created under these Terms has ultimate administrative control over that Organization and the Content within it. Within the Service, an owner can manage User access to the Organization’s data and projects. An Organization may have multiple owners, but there must be at least one User Account designated as an owner of an Organization. If you are the owner of an Organization under these Terms, we consider you responsible for the actions that are performed on or through that Organization.
  • +
+ +

Required Information

+ +

You must provide a valid email address in order to complete the signup process. Any other information requested, such as your real name, is optional, unless you are accepting these terms on behalf of a legal entity (in which case we need more information about the legal entity).

+ +

Account Requirements

+ +
    +
  • You must be a human to create an Account. Accounts registered by "bots" or other automated methods are not permitted. We do permit machine accounts:
  • +
  • A machine account is an Account set up by an individual human who accepts the Terms on behalf of the Account, provides a valid email address, and is responsible for its actions. A machine account is used exclusively for performing automated tasks. Multiple users may direct the actions of a machine account, but the owner of the Account is ultimately responsible for the machine's actions.
  • +
  • You must be age 13 or older. If we learn of any User under that age, we will immediately terminate that User's Account. Different countries may have different minimum age; in such cases you are responsible for complying with your country's regulation. By using Your Gitea Instance, you agree to comply with COPPA and/or similar law in your country.
  • +
+ +

User Account Security

+ +

You are responsible for keeping your Account secure while you use our Service. We offer tools such as two-factor authentication to help you maintain your Account's security, but the content of your Account and its security are up to you.

+ +

Additional Terms

+ +

In some situations, third parties' terms may apply to your use of Your Gitea Instance. For example, you may be a member of an organization on Your Gitea Instance with its own terms or license agreements; you may download an application that integrates with Your Gitea Instance; or you may use Your Gitea Instance to authenticate to another service. Please be aware that while these Terms are our full agreement with you, other parties' terms govern their relationships with you.

+ +

Acceptable Use

+ +

Your use of the Website and Service must not violate any applicable laws, including copyright or trademark laws, export control or sanctions laws, or other laws in your jurisdiction. You are responsible for making sure that your use of the Service is in compliance with laws and any applicable regulations.

+ +

User-Generated Content

+ +
    +
  1. You may create or upload User-Generated Content while using the Service. You are solely responsible for the content of, and for any harm resulting from, any User-Generated Content that you post, upload, link to or otherwise make available via the Service, regardless of the form of that Content. We are not responsible for any public display or misuse of your User-Generated Content.
  2. + +
  3. We do not pre-screen User-Generated Content, but we have the right (though not the obligation) to refuse or remove any User-Generated Content that, in our sole discretion, violates any our terms or policies.
  4. + +
  5. +

    You retain ownership of and responsibility for Your Content. If you're posting anything you did not create yourself or do not own the rights to, you agree that you are responsible for any Content you post; that you will only submit Content that you have the right to post; and that you will fully comply with any third party licenses relating to Content you post.

    + +

    Because of above, we need you to grant us -- and other Your Gitea Instance users -- certain legal permissions, listed below in this section. If you upload Content that already comes with a license granting Your Gitea Instance the permissions we need to run our Service, no additional license is required. You understand that you will not receive any payment for any of the rights granted below. The licenses you grant to us will end when you remove Your Content from our servers, unless other Users have forked it.

    +
  6. + +
  7. +

    We need the legal right to do things like host Your Content, publish it, and share it. You grant us and our legal successors the right to store, parse, and display Your Content, and make incidental copies as necessary to render the Website and provide the Service. This includes the right to do things like copy it to our database and make backups; show it to you and other users; parse it into a search index or otherwise analyze it on our servers; share it with other users; and perform it, in case Your Content is something like music or video.

    + +

    This license, however, doesn't grant Your Gitea Instance the right to sell Your Content or otherwise distribute or use it outside of our provision of the Service.

    +
  8. + +
  9. +

    Any User-Generated Content you post publicly, including issues, comments, and contributions to other Users' repositories, may be viewed by others. By setting your repositories to be viewed publicly, you agree to allow others to view and "fork" your repositories (this means that others may make their own copies of Content from your repositories in repositories they control).

    + +

    If you set your pages and repositories to be viewed publicly, you grant each User of Your Gitea Instance a nonexclusive, worldwide license to use, display, and perform Your Content through the Your Gitea Instance Service and to reproduce Your Content solely on Your Gitea Instance as permitted through Your Gitea Instance's functionality (for example, through forking). You may grant further rights if you adopt a license. If you are uploading Content you did not create or own, you are responsible for ensuring that the Content you upload is licensed under terms that grant these permissions to other Your Gitea Instance Users.

    +
  10. + +
  11. +

    Whenever you make a contribution to a repository containing notice of a license, you license your contribution under the same terms, and you agree that you have the right to license your contribution under those terms. If you have a separate agreement to license your contributions under different terms, such as a contributor license agreement, that agreement will supersede.

    + +

    Isn't this just how it works already? Yep. This is widely accepted as the norm in the open-source community; it's commonly referred to by the shorthand "inbound=outbound". We're just making it explicit.

    +
  12. + +
  13. +

    You retain all moral rights to Your Content that you upload, publish, or submit to any part of the Service, including the rights of integrity and attribution. However, you waive these rights and agree not to assert them against us, to enable us to reasonably exercise the rights granted above, but not otherwise.

    + +

    To the extent this agreement is not enforceable by applicable law, you grant Your Gitea Instance the rights we need to use Your Content without attribution and to make reasonable adaptations of Your Content as necessary to render the Website and provide the Service.

    +
  14. +
+ +

Private Repositories

+ +
    +
  1. Some Accounts may have private repositories, which allow the User to control access to Content.
  2. + +
  3. Your Gitea Instance considers the contents of private repositories to be confidential to you. Your Gitea Instance will protect the contents of private repositories from unauthorized use, access, or disclosure in the same manner that we would use to protect our own confidential information of a similar nature and in no event with less than a reasonable degree of care.
  4. + +
  5. +

    Your Gitea Instance employees may only access the content of your private repositories in the following situations:

    + +
      +
    • With your consent and knowledge, for support reasons. If Your Gitea Instance accesses a private repository for support reasons, we will only do so with the owner’s consent and knowledge.
    • +
    • When access is required for security reasons, including when access is required to maintain ongoing confidentiality, integrity, availability and resilience of Your Gitea Instance's systems and Service.
    • +
    +
  6. + +
  7. You may choose to enable additional access to your private repositories. For example: You may enable various Your Gitea Instance services or features that require additional rights to Your Content in private repositories. These rights may vary depending on the service or feature, but Your Gitea Instance will continue to treat your private repository Content as confidential. If those services or features require rights in addition to those we need to provide the Your Gitea Instance Service, we will provide an explanation of those rights.
  8. +
+ +

Copyright Infringement and DMCA Policy

+ +

If you are copyright owner and believe that content on our website violates your copyright, please contact us at copyright@your-gitea-instance. Please note that before sending a takedown notice, consider legal uses (such as fair use and licensed use); and legal consequences for sending false notices.

+ +

Intellectual Properties and COPYING

+ +

Your Gitea Instance and our licensors, vendors, agents, and/or our content providers retain ownership of all intellectual property rights of any kind related to the Website and Service. We reserve all rights that are not expressly granted to you under this Agreement or by law. The look and feel of the Website and Service is copyright © Your Gitea Instance. All rights reserved.

+ +

If you'd like to use our trademarks, you must follow all of our trademark guidelines.

+ +

This Agreement is licensed under CCO Public Domain License.

+ +

API Terms

+ +

Abuse or excessively frequent requests to Your Gitea Instance via the API may result in the temporary or permanent suspension of your Account's access to the API. Your Gitea Instance, in our sole discretion, will determine abuse or excessive usage of the API. We will make a reasonable attempt to warn you via email prior to suspension.

+ +

You may not share API tokens to exceed Your Gitea Instance's rate limitations.

+ +

You may not use the API to download data or Content from Your Gitea Instance for spamming purposes, including for the purposes of selling Your Gitea Instance users' personal information, such as to recruiters, headhunters, and job boards.

+ +

All use of the Your Gitea Instance API is subject to these Terms of Service and the Your Gitea Instance Privacy Statement.

+ +

However, we may provide subscription-based access to our API for Users who need high-throughput access or reselling our Service.

+ + +

Cancellation and Termination

+ +

Account Cancellation

+ +

It is your responsibility to properly cancel your Account with Your Gitea Instance. You can cancel your Account at any time by going into your Settings in the global navigation bar at the top of the screen. The Account screen provides a simple, no questions asked cancellation link. We are not able to cancel Accounts in response to an email or phone request.

+ +

Upon Cancellation

+ +

We will retain and use your information as necessary to comply with our legal obligations, resolve disputes, and enforce our agreements, but barring legal requirements, we will delete your full profile and the Content of your repositories within 90 days of cancellation or termination (though some information may remain in encrypted backups). This information can not be recovered once your Account is cancelled.

+ +

We will not delete Content that you have contributed to other Users' repositories or that other Users have forked.

+ +

Upon request, we will make a reasonable effort to provide an Account owner with a copy of your lawful, non-infringing Account contents after Account cancellation or termination. You must make this request within 90 days of cancellation or termination.

+ +

We May Terminate

+ +

Your Gitea Instance has the right to suspend or terminate your access to all or any part of the Website at any time, with or without cause, with or without notice, effective immediately. Your Gitea Instance reserves the right to refuse service to anyone for any reason at any time.

+ +

Survival

+ +

All provisions of this Agreement which, by their nature, should survive termination will survive termination — including, without limitation: ownership provisions, warranty disclaimers, indemnity, and limitations of liability.

+ +

Communications with Us

+ +

Electronic Communications Required

+ +

For contractual purposes, you (1) consent to receive communications from us in an electronic form via the email address you have submitted or via the Service; and (2) agree that all Terms of Service, agreements, notices, disclosures, and other communications that we provide to you electronically satisfy any legal requirement that those communications would satisfy if they were on paper. This section does not affect your non-waivable rights.

+ +

Legal Notices to Us Must Be in Writing

+ +

Communications made through email or Your Gitea Instance Support's messaging system will not constitute legal notice to Your Gitea Instance or any of its officers, employees, agents or representatives in any situation where notice to Your Gitea Instance is required by contract or any law or regulation. Legal notice to Your Gitea Instance must be in writing and served on Your Gitea Instance's legal agent.

+ +

No Phone Support

+ +

We only offer support via email, in-Service communications, and electronic messages. We do not offer telephone support.

+ +

Disclaimer of Warranties

+ +

Your Gitea Instance provides the Website and the Service “as is” and “as available,” without warranty of any kind. Without limiting this, we expressly disclaim all warranties, whether express, implied or statutory, regarding the Website and the Service including without limitation any warranty of merchantability, fitness for a particular purpose, title, security, accuracy and non-infringement.

+ +

Your Gitea Instance does not warrant that the Service will meet your requirements; that the Service will be uninterrupted, timely, secure, or error-free; that the information provided through the Service is accurate, reliable or correct; that any defects or errors will be corrected; that the Service will be available at any particular time or location; or that the Service is free of viruses or other harmful components. You assume full responsibility and risk of loss resulting from your downloading and/or use of files, information, content or other material obtained from the Service.

+ +

Limitation of Liability

+ +

You understand and agree that we will not be liable to you or any third party for any loss of profits, use, goodwill, or data, or for any incidental, indirect, special, consequential or exemplary damages, however arising, that result from:

+ +
    +
  • the use, disclosure, or display of your User-Generated Content;
  • + +
  • your use or inability to use the Service;
  • + +
  • any modification, price change, suspension or discontinuance of the Service;
  • + +
  • the Service generally or the software or systems that make the Service available;
  • + +
  • unauthorized access to or alterations of your transmissions or data;
  • + +
  • statements or conduct of any third party on the Service;
  • + +
  • any other user interactions that you input or receive through your use of the Service; or + any other matter relating to the Service.
  • +
+ +

Our liability is limited whether or not we have been informed of the possibility of such damages, and even if a remedy set forth in this Agreement is found to have failed of its essential purpose. We will have no liability for any failure or delay due to matters beyond our reasonable control.

+ +

Release and Indemnification

+ +

If you have a dispute with one or more Users, you agree to release Your Gitea Instance from any and all claims, demands and damages (actual and consequential) of every kind and nature, known and unknown, arising out of or in any way connected with such disputes.

+ +

You agree to indemnify us, defend us, and hold us harmless from and against any and all claims, liabilities, and expenses, including attorneys’ fees, arising out of your use of the Website and the Service, including but not limited to your violation of this Agreement, provided that Your Gitea Instance (1) promptly gives you written notice of the claim, demand, suit or proceeding; (2) gives you sole control of the defense and settlement of the claim, demand, suit or proceeding (provided that you may not settle any claim, demand, suit or proceeding unless the settlement unconditionally releases Your Gitea Instance of all liability); and (3) provides to you all reasonable assistance, at your expense.

+ +

Changes to These Terms

+ +

We reserve the right, at our sole discretion, to amend these Terms of Service at any time and will update these Terms of Service in the event of any such amendments. We will notify our Users of material changes to this Agreement, such as price changes, at least 30 days prior to the change taking effect by posting a notice on our Website. For non-material modifications, your continued use of the Website constitutes agreement to our revisions of these Terms of Service.

+ +

Miscellaneous

+ +

Governing Law

+ +

Except to the extent applicable law provides otherwise, this Agreement between you and us and any access to or use of the Website or the Service are governed by (national laws of country/state where Gitea is deployed) and (regional laws of locality where Gitea is deployed), without regard to conflict of law provisions. You and Your Gitea Instance agree to submit to the exclusive jurisdiction and venue of the courts located in (locality where Gitea is deployed).

+ +

Non-Assignability

+ +

Your Gitea Instance may assign or delegate these Terms of Service and/or our Privacy Policy in whole or in part, to any person or entity at any time with or without your consent, including the license granted in User-Generated Content. You may not assign or delegate any rights or obligations under the Terms of Service or Privacy Statement without our prior written consent, and any unauthorized assignment and delegation by you is void.

+ +

Severablity, No Waiver, and Survival

+ +

If any part of this Agreement is held invalid or unenforceable, that portion of the Agreement will be construed to reflect the parties’ original intent. The remaining portions will remain in full force and effect. Any failure on the part of Your Gitea Instance to enforce any provision of this Agreement will not be considered a waiver of our right to enforce such provision. Our rights under this Agreement will survive any termination of this Agreement.

+ +

Amendments and Complete Agreement

+ +

This Agreement may only be modified by a written amendment signed by an authorized representative of Your Gitea Instance, or by the posting by Your Gitea Instance of a revised version in accordance with Changes to These Terms. These Terms of Service, together with the Your Gitea Instance Privacy Policy, represent the complete and exclusive statement of the agreement between you and us. This Agreement supersedes any proposal or prior agreement oral or written, and any other communications between you and Your Gitea Instance relating to the subject matter of these terms including any confidentiality or nondisclosure agreements.

+ +

Contact

+ +

If you have questions about these Terms of Service, you can contact our support.

+ + diff --git a/contrib/service/freebsd/gitea b/contrib/service/freebsd/gitea new file mode 100644 index 0000000..2c034ce --- /dev/null +++ b/contrib/service/freebsd/gitea @@ -0,0 +1,48 @@ +#!/bin/sh +# +# $FreeBSD$ +# +# PROVIDE: gitea +# REQUIRE: NETWORKING SYSLOG +# KEYWORD: shutdown +# +# Add the following lines to /etc/rc.conf to enable gitea: +# +#gitea_enable="YES" + +. /etc/rc.subr + +name="gitea" +rcvar="gitea_enable" + +load_rc_config $name + +: ${gitea_user:="git"} +: ${gitea_enable:="NO"} +: ${gitea_directory:="/var/lib/gitea"} + +command="/usr/local/bin/gitea web -c /etc/gitea/app.ini" +procname="$(echo $command |cut -d' ' -f1)" + +pidfile="${gitea_directory}/${name}.pid" + +start_cmd="${name}_start" +stop_cmd="${name}_stop" + +gitea_start() { + cd ${gitea_directory} + export USER=${gitea_user} + export HOME=/usr/home/${gitea_user} + export GITEA_WORK_DIR=${gitea_directory} + /usr/sbin/daemon -f -u ${gitea_user} -p ${pidfile} $command +} + +gitea_stop() { + if [ ! -f $pidfile ]; then + echo "GITEA PID File not found. Maybe GITEA is not running?" + else + kill $(cat $pidfile) + fi +} + +run_rc_command "$1" diff --git a/contrib/service/gentoo/gitea b/contrib/service/gentoo/gitea new file mode 100644 index 0000000..db904e7 --- /dev/null +++ b/contrib/service/gentoo/gitea @@ -0,0 +1,44 @@ +#!/sbin/openrc-run + +DIR=/var/lib/gitea +USER=git +HOME=/home/${USER} +GITEA_WORK_DIR=${DIR} +EXECUTABLE=/usr/local/bin/gitea + +export USER +export HOME +export GITEA_WORK_DIR + +name=$RC_SVCNAME +cfgfile="/etc/$RC_SVCNAME/app.ini" +command="${EXECUTABLE}" +command_user="${USER}" +command_args="web -c /etc/$RC_SVCNAME/app.ini" +command_background="yes" +pidfile="/run/$RC_SVCNAME/$RC_SVCNAME.pid" +start_stop_daemon_args="--user ${USER} --chdir ${DIR}" + +depend() +{ + need net + ### + # Don't forget to add the database service requirements + ### + #after postgresql + #after mysql + #after mariadb + #after memcached + #after redis +} + +start_pre() +{ + checkpath --directory --owner $command_user:$command_user --mode 0750 \ + /run/$RC_SVCNAME /var/log/$RC_SVCNAME + ## + # If you want to bind Gitea to a port below 1024, uncomment + # the value below + ## + #setcap cap_net_bind_service=+ep "${EXECUTABLE}" +} diff --git a/contrib/service/launchd/io.gitea.web.plist b/contrib/service/launchd/io.gitea.web.plist new file mode 100644 index 0000000..43ec612 --- /dev/null +++ b/contrib/service/launchd/io.gitea.web.plist @@ -0,0 +1,39 @@ + + + + + Label + io.gitea.web + + + UserName + git + GroupName + git + ProgramArguments + + + + /Users/git/gitea/gitea + web + + RunAtLoad + + KeepAlive + + + + WorkingDirectory + /Users/git/gitea/ + StandardOutPath + /Users/git/gitea/log/stdout.log + StandardErrorPath + /Users/git/gitea/log/stderr.log + + SoftResourceLimits + + NumberOfFiles + 8192 + + + diff --git a/contrib/service/openbsd/gitea b/contrib/service/openbsd/gitea new file mode 100755 index 0000000..89d4f68 --- /dev/null +++ b/contrib/service/openbsd/gitea @@ -0,0 +1,19 @@ +#!/bin/sh +# +# $OpenBSD$ + +daemon="/usr/local/bin/gitea" +daemon_user="git" +daemon_flags="web -c /etc/gitea/app.ini" + +gitea_directory="/var/lib/gitea" + +rc_bg=YES + +. /etc/rc.d/rc.subr + +rc_start() { + ${rcexec} "cd ${gitea_directory}; ${daemon} ${daemon_flags} ${_bg}" +} + +rc_cmd $1 diff --git a/contrib/service/openwrt/gitea b/contrib/service/openwrt/gitea new file mode 100644 index 0000000..67b4495 --- /dev/null +++ b/contrib/service/openwrt/gitea @@ -0,0 +1,35 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 + +# PROCD_DEBUG=1 + +START=90 +STOP=10 + +PROG=/opt/gitea/gitea +GITEA_WORK_DIR=/opt/gitea +CONF_FILE=$GITEA_WORK_DIR/app.ini + +start_service(){ + procd_open_instance gitea + procd_set_param env GITEA_WORK_DIR=$GITEA_WORK_DIR + procd_set_param env HOME=$GITEA_WORK_DIR + procd_set_param command $PROG web -c $CONF_FILE + procd_set_param file $CONF_FILE + procd_set_param user git + procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5} # respawn automatically if something died, be careful if you have an alternative process supervisor + procd_close_instance +} + +start(){ + service_start $PROG +} + +stop(){ + service_stop $PROG +} + +reload(){ + service_reload $PROG +} diff --git a/contrib/service/sunos/gitea.xml b/contrib/service/sunos/gitea.xml new file mode 100644 index 0000000..4b8cc3a --- /dev/null +++ b/contrib/service/sunos/gitea.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contrib/service/supervisor/gitea b/contrib/service/supervisor/gitea new file mode 100644 index 0000000..a8a908d --- /dev/null +++ b/contrib/service/supervisor/gitea @@ -0,0 +1,16 @@ +[program:gitea] +directory=/home/git/go/src/github.com/go-gitea/gitea/ +command=/home/git/go/src/github.com/go-gitea/gitea/gitea web +autostart=true +autorestart=true +startsecs=10 +stdout_logfile=/var/log/gitea/stdout.log +stdout_logfile_maxbytes=1MB +stdout_logfile_backups=10 +stdout_capture_maxbytes=1MB +stderr_logfile=/var/log/gitea/stderr.log +stderr_logfile_maxbytes=1MB +stderr_logfile_backups=10 +stderr_capture_maxbytes=1MB +user = git +environment = HOME="/home/git", USER="git" \ No newline at end of file diff --git a/contrib/service/systemd/gitea.service b/contrib/service/systemd/gitea.service new file mode 100644 index 0000000..c091722 --- /dev/null +++ b/contrib/service/systemd/gitea.service @@ -0,0 +1,85 @@ +[Unit] +Description=Gitea (Git with a cup of tea) +After=network.target +### +# Don't forget to add the database service dependencies +### +# +#Wants=mysql.service +#After=mysql.service +# +#Wants=mariadb.service +#After=mariadb.service +# +#Wants=postgresql.service +#After=postgresql.service +# +#Wants=memcached.service +#After=memcached.service +# +#Wants=redis.service +#After=redis.service +# +### +# If using socket activation for main http/s +### +# +#After=gitea.main.socket +#Requires=gitea.main.socket +# +### +# (You can also provide gitea an http fallback and/or ssh socket too) +# +# An example of /etc/systemd/system/gitea.main.socket +### +## +## [Unit] +## Description=Gitea Web Socket +## PartOf=gitea.service +## +## [Socket] +## Service=gitea.service +## ListenStream= +## NoDelay=true +## +## [Install] +## WantedBy=sockets.target +## +### + +[Service] +# Uncomment the next line if you have repos with lots of files and get a HTTP 500 error because of that +# LimitNOFILE=524288:524288 +RestartSec=2s +Type=simple +User=git +Group=git +WorkingDirectory=/var/lib/gitea/ +# If using Unix socket: tells systemd to create the /run/gitea folder, which will contain the gitea.sock file +# (manually creating /run/gitea doesn't work, because it would not persist across reboots) +#RuntimeDirectory=gitea +ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini +Restart=always +Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea +# If you install Git to directory prefix other than default PATH (which happens +# for example if you install other versions of Git side-to-side with +# distribution version), uncomment below line and add that prefix to PATH +# Don't forget to place git-lfs binary on the PATH below if you want to enable +# Git LFS support +#Environment=PATH=/path/to/git/bin:/bin:/sbin:/usr/bin:/usr/sbin +# If you want to bind Gitea to a port below 1024, uncomment +# the two values below, or use socket activation to pass Gitea its ports as above +### +#CapabilityBoundingSet=CAP_NET_BIND_SERVICE +#AmbientCapabilities=CAP_NET_BIND_SERVICE +### +# In some cases, when using CapabilityBoundingSet and AmbientCapabilities option, you may want to +# set the following value to false to allow capabilities to be applied on gitea process. The following +# value if set to true sandboxes gitea service and prevent any processes from running with privileges +# in the host user namespace. +### +#PrivateUsers=false +### + +[Install] +WantedBy=multi-user.target diff --git a/contrib/service/sysvinit/gitea b/contrib/service/sysvinit/gitea new file mode 100644 index 0000000..1508da7 --- /dev/null +++ b/contrib/service/sysvinit/gitea @@ -0,0 +1,89 @@ +#!/bin/bash +### BEGIN INIT INFO +# Provides: gitea +# Required-Start: $syslog $network +# Required-Stop: $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: A self-hosted Git service written in Go. +# Description: A self-hosted Git service written in Go. +### END INIT INFO + +# This is a System V Init (init.d) startup script for legacy Linux distributions + +# Do NOT "set -e" + +# PATH should only include /usr/* if it runs after the mountnfs.sh script +PATH=/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin +DESC="Gitea - Git with a cup of tea" +NAME=gitea +SERVICEVERBOSE=yes +PIDFILE=/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME +WORKINGDIR=/var/lib/$NAME +DAEMON=/usr/local/bin/$NAME +DAEMON_ARGS="web -c /etc/$NAME/app.ini" +USER=git +STOP_SCHEDULE="${STOP_SCHEDULE:-QUIT/5/TERM/1/KILL/5}" + +# If you want to bind Gitea to a port below 1024, apply "setcap" to the gitea binary +#setcap cap_net_bind_service=+ep $DAEMON" + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +do_start() +{ + GITEA_ENVS="USER=$USER GITEA_WORK_DIR=$WORKINGDIR HOME=/home/$USER" + GITEA_EXEC="$DAEMON -- $DAEMON_ARGS" + sh -c "start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile \\ + --background --chdir $WORKINGDIR --chuid $USER \\ + --exec /bin/bash -- -c '/usr/bin/env $GITEA_ENVS $GITEA_EXEC'" +} + +do_stop() +{ + start-stop-daemon --stop --quiet --retry=$STOP_SCHEDULE --pidfile $PIDFILE --name $NAME --oknodo + rm -f $PIDFILE +} + +do_status() +{ + if [ -f $PIDFILE ]; then + if kill -0 $(cat "$PIDFILE"); then + echo "$NAME is running, PID is $(cat $PIDFILE)" + else + echo "$NAME process is dead, but pidfile exists" + fi + else + echo "$NAME is not running" + fi +} + +case "$1" in + start) + echo "Starting $DESC" "$NAME" + do_start + ;; + stop) + echo "Stopping $DESC" "$NAME" + do_stop + ;; + status) + do_status + ;; + restart) + echo "Restarting $DESC" "$NAME" + do_stop + do_start + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart}" >&2 + exit 2 + ;; +esac + +exit 0 diff --git a/contrib/upgrade.sh b/contrib/upgrade.sh new file mode 100755 index 0000000..71d0435 --- /dev/null +++ b/contrib/upgrade.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# This is an update script for gitea installed via the binary distribution +# from dl.gitea.com on linux as systemd service. It performs a backup and updates +# Gitea in place. +# NOTE: This adds the GPG Signing Key of the Gitea maintainers to the keyring. +# Depends on: bash, curl, xz, sha256sum. optionally jq, gpg +# See section below for available environment vars. +# When no version is specified, updates to the latest release. +# Examples: +# upgrade.sh 1.15.10 +# giteahome=/opt/gitea giteaconf=$giteahome/app.ini upgrade.sh + +# Check if gitea service is running +if ! pidof gitea &> /dev/null; then + echo "Error: gitea is not running." + exit 1 +fi + +# Continue with rest of the script if gitea is running +echo "Gitea is running. Continuing with rest of script..." + +# apply variables from environment +: "${giteabin:="/usr/local/bin/gitea"}" +: "${giteahome:="/var/lib/gitea"}" +: "${giteaconf:="/etc/gitea/app.ini"}" +: "${giteauser:="git"}" +: "${sudocmd:="sudo"}" +: "${arch:="linux-amd64"}" +: "${service_start:="$sudocmd systemctl start gitea"}" +: "${service_stop:="$sudocmd systemctl stop gitea"}" +: "${service_status:="$sudocmd systemctl status gitea"}" +: "${backupopts:=""}" # see `gitea dump --help` for available options + +function giteacmd { + if [[ $sudocmd = "su" ]]; then + # `-c` only accept one string as argument. + "$sudocmd" - "$giteauser" -c "$(printf "%q " "$giteabin" "--config" "$giteaconf" "--work-path" "$giteahome" "$@")" + else + "$sudocmd" --user "$giteauser" "$giteabin" --config "$giteaconf" --work-path "$giteahome" "$@" + fi +} + +function require { + for exe in "$@"; do + command -v "$exe" &>/dev/null || (echo "missing dependency '$exe'"; exit 1) + done +} + +# parse command line arguments +while true; do + case "$1" in + -v | --version ) giteaversion="$2"; shift 2 ;; + -y | --yes ) no_confirm="yes"; shift ;; + --ignore-gpg) ignore_gpg="yes"; shift ;; + "" | -- ) shift; break ;; + * ) echo "Usage: [] upgrade.sh [-v ] [-y] [--ignore-gpg]"; exit 1;; + esac +done + +# exit once any command fails. this means that each step should be idempotent! +set -euo pipefail + +if [[ -f /etc/os-release ]]; then + os_release=$(cat /etc/os-release) + + if [[ "$os_release" =~ "OpenWrt" ]]; then + sudocmd="su" + service_start="/etc/init.d/gitea start" + service_stop="/etc/init.d/gitea stop" + service_status="/etc/init.d/gitea status" + else + require systemctl + fi +fi + +require curl xz sha256sum "$sudocmd" + +# select version to install +if [[ -z "${giteaversion:-}" ]]; then + require jq + giteaversion=$(curl --connect-timeout 10 -sL https://dl.gitea.com/gitea/version.json | jq -r .latest.version) + echo "Latest available version is $giteaversion" +fi + +# confirm update +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 "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)" + read -r confirm + [[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1 +fi + +echo "Upgrading gitea from $current to $giteaversion ..." + +pushd "$(pwd)" &>/dev/null +cd "$giteahome" # needed for gitea dump later + +# download new binary +binname="gitea-${giteaversion}-${arch}" +binurl="https://dl.gitea.com/gitea/${giteaversion}/${binname}.xz" +echo "Downloading $binurl..." +curl --connect-timeout 10 --silent --show-error --fail --location -O "$binurl{,.sha256,.asc}" + +# validate checksum & gpg signature +sha256sum -c "${binname}.xz.sha256" +if [[ -z "${ignore_gpg:-}" ]]; then + require gpg + # try to use curl first, it uses standard tcp 443 port and works better behind strict firewall rules + curl -fsSL --connect-timeout 10 "https://keys.openpgp.org/vks/v1/by-fingerprint/7C9E68152594688862D62AF62D9AE806EC1592E2" | gpg --import \ + || gpg --keyserver keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2 + gpg --verify "${binname}.xz.asc" "${binname}.xz" || { echo 'Signature does not match'; exit 1; } +fi +rm "${binname}".xz.{sha256,asc} + +# unpack binary + make executable +xz --decompress --force "${binname}.xz" +chown "$giteauser" "$binname" +chmod +x "$binname" + +# stop gitea, create backup, replace binary, restart gitea +echo "Flushing gitea queues at $(date)" +giteacmd manager flush-queues +echo "Stopping gitea at $(date)" +$service_stop +echo "Creating backup in $giteahome" +# shellcheck disable=SC2086 # flag string +giteacmd dump $backupopts +echo "Updating binary at $giteabin" +cp -f "$giteabin" "$giteabin.bak" && mv -f "$binname" "$giteabin" +# Restore SELinux context if applicable (e.g. RHEL/Fedora) +command -v restorecon &>/dev/null && restorecon -v "$giteabin" || true +$service_start +$service_status + +echo "Upgrade to $giteaversion successful!" + +popd diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..d14d484 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,12 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_KEY +base_path: "." +base_url: "https://api.crowdin.com" +preserve_hierarchy: true +files: + - source: "/options/locale/locale_en-US.json" + translation: "/options/locale/locale_%locale%.json" + type: "json" + skip_untranslated_strings: true + export_only_approved: true + update_option: "update_as_unapproved" diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..b014f42 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,7 @@ +# Gitea - Docker + +Dockerfile is found in the root of the repository. + +Docker image can be found on [docker hub](https://hub.docker.com/r/gitea/gitea). + +Documentation on using docker image can be found on [Gitea Docs site](https://docs.gitea.com/installation/install-with-docker-rootless). diff --git a/docker/manifest.rootless.tmpl b/docker/manifest.rootless.tmpl new file mode 100644 index 0000000..3fa94ab --- /dev/null +++ b/docker/manifest.rootless.tmpl @@ -0,0 +1,29 @@ +image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-rootless +{{#if build.tags}} +{{#unless (contains "-rc" build.tag)}} +{{#unless (contains "-dev" build.tag)}} +tags: +{{#each build.tags}} + - {{this}}-rootless +{{/each}} + - "latest-rootless" +{{/unless}} +{{/unless}} +{{/if}} +manifests: + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-amd64-rootless + platform: + architecture: amd64 + os: linux + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-arm64-rootless + platform: + architecture: arm64 + os: linux + variant: v8 + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-riscv64-rootless + platform: + architecture: riscv64 + os: linux diff --git a/docker/manifest.tmpl b/docker/manifest.tmpl new file mode 100644 index 0000000..c68ca46 --- /dev/null +++ b/docker/manifest.tmpl @@ -0,0 +1,29 @@ +image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}} +{{#if build.tags}} +{{#unless (contains "-rc" build.tag)}} +{{#unless (contains "-dev" build.tag)}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} + - "latest" +{{/unless}} +{{/unless}} +{{/if}} +manifests: + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-amd64 + platform: + architecture: amd64 + os: linux + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - + image: gitea/gitea:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}{{#if (hasPrefix "refs/heads/release/v" build.ref)}}{{trimPrefix "refs/heads/release/v" build.ref}}-{{/if}}nightly{{/if}}-linux-riscv64 + platform: + architecture: riscv64 + os: linux diff --git a/docker/root/etc/nsswitch.conf b/docker/root/etc/nsswitch.conf new file mode 100644 index 0000000..25fad99 --- /dev/null +++ b/docker/root/etc/nsswitch.conf @@ -0,0 +1,15 @@ +# /etc/nsswitch.conf + +passwd: compat +group: compat +shadow: compat + +hosts: files dns +networks: files + +protocols: db files +services: db files +ethers: db files +rpc: db files + +netgroup: nis diff --git a/docker/root/etc/s6/.s6-svscan/finish b/docker/root/etc/s6/.s6-svscan/finish new file mode 100755 index 0000000..06bd986 --- /dev/null +++ b/docker/root/etc/s6/.s6-svscan/finish @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/docker/root/etc/s6/gitea/finish b/docker/root/etc/s6/gitea/finish new file mode 100755 index 0000000..5d44f41 --- /dev/null +++ b/docker/root/etc/s6/gitea/finish @@ -0,0 +1,2 @@ +#!/bin/bash +s6-svscanctl -t /etc/s6/ diff --git a/docker/root/etc/s6/gitea/run b/docker/root/etc/s6/gitea/run new file mode 100755 index 0000000..7b85835 --- /dev/null +++ b/docker/root/etc/s6/gitea/run @@ -0,0 +1,6 @@ +#!/bin/bash +[[ -f ./setup ]] && source ./setup + +pushd /app/gitea >/dev/null +exec su-exec $USER /usr/local/bin/gitea web +popd diff --git a/docker/root/etc/s6/gitea/setup b/docker/root/etc/s6/gitea/setup new file mode 100755 index 0000000..b801ef4 --- /dev/null +++ b/docker/root/etc/s6/gitea/setup @@ -0,0 +1,66 @@ +#!/bin/bash + +if [ ! -d /data/git/.ssh ]; then + mkdir -p /data/git/.ssh +fi + +# Set the correct permissions on the .ssh directory and authorized_keys file, +# or sshd will refuse to use them and lead to clone/push/pull failures. +# It could happen when users have copied their data to a new volume and changed the file permission by accident, +# and it would be very hard to troubleshoot unless users know how to check the logs of sshd which is started by s6. +chmod 700 /data/git/.ssh +if [ -f /data/git/.ssh/authorized_keys ]; then + chmod 600 /data/git/.ssh/authorized_keys +fi + +if [ ! -f /data/git/.ssh/environment ]; then + echo "GITEA_CUSTOM=$GITEA_CUSTOM" >| /data/git/.ssh/environment + chmod 600 /data/git/.ssh/environment + +elif ! grep -q "^GITEA_CUSTOM=$GITEA_CUSTOM$" /data/git/.ssh/environment; then + sed -i /^GITEA_CUSTOM=/d /data/git/.ssh/environment + echo "GITEA_CUSTOM=$GITEA_CUSTOM" >> /data/git/.ssh/environment +fi + +if [ ! -f ${GITEA_CUSTOM}/conf/app.ini ]; then + mkdir -p ${GITEA_CUSTOM}/conf + + # Set INSTALL_LOCK to true only if SECRET_KEY is not empty and + # INSTALL_LOCK is empty + if [ -n "$SECRET_KEY" ] && [ -z "$INSTALL_LOCK" ]; then + INSTALL_LOCK=true + fi + + # Substitute the environment variables in the template + APP_NAME=${APP_NAME:-"Gitea: Git with a cup of tea"} \ + RUN_MODE=${RUN_MODE:-"prod"} \ + DOMAIN=${DOMAIN:-"localhost"} \ + SSH_DOMAIN=${SSH_DOMAIN:-"localhost"} \ + HTTP_PORT=${HTTP_PORT:-"3000"} \ + ROOT_URL=${ROOT_URL:-""} \ + DISABLE_SSH=${DISABLE_SSH:-"false"} \ + SSH_PORT=${SSH_PORT:-"22"} \ + SSH_LISTEN_PORT=${SSH_LISTEN_PORT:-"${SSH_PORT}"} \ + LFS_START_SERVER=${LFS_START_SERVER:-"false"} \ + DB_TYPE=${DB_TYPE:-"sqlite3"} \ + DB_HOST=${DB_HOST:-"localhost:3306"} \ + DB_NAME=${DB_NAME:-"gitea"} \ + DB_USER=${DB_USER:-"root"} \ + DB_PASSWD=${DB_PASSWD:-""} \ + INSTALL_LOCK=${INSTALL_LOCK:-"false"} \ + DISABLE_REGISTRATION=${DISABLE_REGISTRATION:-"false"} \ + REQUIRE_SIGNIN_VIEW=${REQUIRE_SIGNIN_VIEW:-"false"} \ + SECRET_KEY=${SECRET_KEY:-""} \ + envsubst < /etc/templates/app.ini > ${GITEA_CUSTOM}/conf/app.ini + + chown ${USER}:git ${GITEA_CUSTOM}/conf/app.ini +fi + +# Replace app.ini settings with env variables in the form GITEA__SECTION_NAME__KEY_NAME +environment-to-ini --config ${GITEA_CUSTOM}/conf/app.ini + +# only chown if current owner is not already the gitea ${USER}. No recursive check to save time +if ! [[ $(ls -ld /data/gitea | awk '{print $3}') = ${USER} ]]; then chown -R ${USER}:git /data/gitea; fi +if ! [[ $(ls -ld /app/gitea | awk '{print $3}') = ${USER} ]]; then chown -R ${USER}:git /app/gitea; fi +if ! [[ $(ls -ld /data/git | awk '{print $3}') = ${USER} ]]; then chown -R ${USER}:git /data/git; fi +chmod 0755 /data/gitea /app/gitea /data/git diff --git a/docker/root/etc/s6/openssh/finish b/docker/root/etc/s6/openssh/finish new file mode 100755 index 0000000..8e89b35 --- /dev/null +++ b/docker/root/etc/s6/openssh/finish @@ -0,0 +1,8 @@ +#!/bin/bash +# $1 = exit code of the run script, $2 = signal +if [ "$1" -ne 0 ]; then + # avoid immediately restarting the sshd service, which may cause CPU 100% if the error (permission, configuration) is not fixed + echo "openssh failed with exit code $1 - waiting a short delay before attempting a restart" + sleep 3 +fi +exit 0 diff --git a/docker/root/etc/s6/openssh/run b/docker/root/etc/s6/openssh/run new file mode 100755 index 0000000..a40b5b1 --- /dev/null +++ b/docker/root/etc/s6/openssh/run @@ -0,0 +1,6 @@ +#!/bin/bash +[[ -f ./setup ]] && source ./setup + +pushd /root >/dev/null +exec su-exec root /usr/sbin/sshd -D -e 2>&1 +popd diff --git a/docker/root/etc/s6/openssh/setup b/docker/root/etc/s6/openssh/setup new file mode 100755 index 0000000..48e7d4b --- /dev/null +++ b/docker/root/etc/s6/openssh/setup @@ -0,0 +1,66 @@ +#!/bin/bash + +if [ ! -d /data/ssh ]; then + mkdir -p /data/ssh +fi + +if [ ! -f /data/ssh/ssh_host_ed25519_key ]; then + echo "Generating /data/ssh/ssh_host_ed25519_key..." + ssh-keygen -t ed25519 -f /data/ssh/ssh_host_ed25519_key -N "" > /dev/null +fi + +if [ ! -f /data/ssh/ssh_host_rsa_key ]; then + echo "Generating /data/ssh/ssh_host_rsa_key..." + ssh-keygen -t rsa -b 3072 -f /data/ssh/ssh_host_rsa_key -N "" > /dev/null +fi + +if [ ! -f /data/ssh/ssh_host_ecdsa_key ]; then + echo "Generating /data/ssh/ssh_host_ecdsa_key..." + ssh-keygen -t ecdsa -b 256 -f /data/ssh/ssh_host_ecdsa_key -N "" > /dev/null +fi + +if [ -e /data/ssh/ssh_host_ed25519_cert ]; then + SSH_ED25519_CERT=${SSH_ED25519_CERT:-"/data/ssh/ssh_host_ed25519_cert"} +fi + +if [ -e /data/ssh/ssh_host_rsa_cert ]; then + SSH_RSA_CERT=${SSH_RSA_CERT:-"/data/ssh/ssh_host_rsa_cert"} +fi + +if [ -e /data/ssh/ssh_host_ecdsa_cert ]; then + SSH_ECDSA_CERT=${SSH_ECDSA_CERT:-"/data/ssh/ssh_host_ecdsa_cert"} +fi + +# In case someone wants to sign the `{keyname}.pub` key by `ssh-keygen -s ca -I identity ...` to +# make use of the ssh-key certificate authority feature (see ssh-keygen CERTIFICATES section), +# the generated key file name is `{keyname}-cert.pub` +if [ -e /data/ssh/ssh_host_ed25519_key-cert.pub ]; then + SSH_ED25519_CERT=${SSH_ED25519_CERT:-"/data/ssh/ssh_host_ed25519_key-cert.pub"} +fi + +if [ -e /data/ssh/ssh_host_rsa_key-cert.pub ]; then + SSH_RSA_CERT=${SSH_RSA_CERT:-"/data/ssh/ssh_host_rsa_key-cert.pub"} +fi + +if [ -e /data/ssh/ssh_host_ecdsa_key-cert.pub ]; then + SSH_ECDSA_CERT=${SSH_ECDSA_CERT:-"/data/ssh/ssh_host_ecdsa_key-cert.pub"} +fi + +if [ -d /etc/ssh ]; then + SSH_PORT=${SSH_PORT:-"22"} \ + SSH_LISTEN_PORT=${SSH_LISTEN_PORT:-"${SSH_PORT}"} \ + SSH_ED25519_CERT="${SSH_ED25519_CERT:+"HostCertificate "}${SSH_ED25519_CERT}" \ + SSH_RSA_CERT="${SSH_RSA_CERT:+"HostCertificate "}${SSH_RSA_CERT}" \ + SSH_ECDSA_CERT="${SSH_ECDSA_CERT:+"HostCertificate "}${SSH_ECDSA_CERT}" \ + SSH_MAX_STARTUPS="${SSH_MAX_STARTUPS:+"MaxStartups "}${SSH_MAX_STARTUPS}" \ + SSH_MAX_SESSIONS="${SSH_MAX_SESSIONS:+"MaxSessions "}${SSH_MAX_SESSIONS}" \ + SSH_INCLUDE_FILE="${SSH_INCLUDE_FILE:+"Include "}${SSH_INCLUDE_FILE}" \ + SSH_LOG_LEVEL=${SSH_LOG_LEVEL:-"INFO"} \ + envsubst < /etc/templates/sshd_config > /etc/ssh/sshd_config + + chmod 0644 /etc/ssh/sshd_config +fi + +chown root:root /data/ssh/* +chmod 0700 /data/ssh +chmod 0600 /data/ssh/* diff --git a/docker/root/etc/templates/app.ini b/docker/root/etc/templates/app.ini new file mode 100644 index 0000000..1794d1f --- /dev/null +++ b/docker/root/etc/templates/app.ini @@ -0,0 +1,60 @@ +APP_NAME = $APP_NAME +RUN_MODE = $RUN_MODE + +[repository] +ROOT = /data/git/repositories + +[repository.local] +LOCAL_COPY_PATH = /data/gitea/tmp/local-repo + +[repository.upload] +TEMP_PATH = /data/gitea/uploads + +[server] +APP_DATA_PATH = /data/gitea +DOMAIN = $DOMAIN +SSH_DOMAIN = $SSH_DOMAIN +HTTP_PORT = $HTTP_PORT +ROOT_URL = $ROOT_URL +DISABLE_SSH = $DISABLE_SSH +SSH_PORT = $SSH_PORT +SSH_LISTEN_PORT = $SSH_LISTEN_PORT +LFS_START_SERVER = $LFS_START_SERVER + +[database] +PATH = /data/gitea/gitea.db +DB_TYPE = $DB_TYPE +HOST = $DB_HOST +NAME = $DB_NAME +USER = $DB_USER +PASSWD = $DB_PASSWD +LOG_SQL = false + +[indexer] +ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve + +[session] +PROVIDER_CONFIG = /data/gitea/sessions + +[picture] +AVATAR_UPLOAD_PATH = /data/gitea/avatars +REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars + +[attachment] +PATH = /data/gitea/attachments + +[log] +MODE = console +LEVEL = info +ROOT_PATH = /data/gitea/log + +[security] +INSTALL_LOCK = $INSTALL_LOCK +SECRET_KEY = $SECRET_KEY + +[service] +DISABLE_REGISTRATION = $DISABLE_REGISTRATION +REQUIRE_SIGNIN_VIEW = $REQUIRE_SIGNIN_VIEW + +[lfs] +PATH = /data/git/lfs diff --git a/docker/root/etc/templates/sshd_config b/docker/root/etc/templates/sshd_config new file mode 100644 index 0000000..033c434 --- /dev/null +++ b/docker/root/etc/templates/sshd_config @@ -0,0 +1,43 @@ +Port ${SSH_LISTEN_PORT} +Protocol 2 + +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +${SSH_MAX_STARTUPS} +${SSH_MAX_SESSIONS} + +LogLevel ${SSH_LOG_LEVEL} + +HostKey /data/ssh/ssh_host_ed25519_key +${SSH_ED25519_CERT} +HostKey /data/ssh/ssh_host_rsa_key +${SSH_RSA_CERT} +HostKey /data/ssh/ssh_host_ecdsa_key +${SSH_ECDSA_CERT} + +AuthorizedKeysFile .ssh/authorized_keys +AuthorizedPrincipalsFile .ssh/authorized_principals +TrustedUserCAKeys /data/git/.ssh/gitea-trusted-user-ca-keys.pem +CASignatureAlgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256,ssh-rsa + +UseDNS no +AllowAgentForwarding no +AllowTcpForwarding no +PrintMotd no + +PermitUserEnvironment yes +PermitRootLogin no +ChallengeResponseAuthentication no +PasswordAuthentication no +PermitEmptyPasswords no + +AllowUsers ${USER} + +Banner none +Subsystem sftp /usr/lib/ssh/sftp-server + +AcceptEnv GIT_PROTOCOL + +${SSH_INCLUDE_FILE} diff --git a/docker/root/usr/bin/entrypoint b/docker/root/usr/bin/entrypoint new file mode 100755 index 0000000..08587fc --- /dev/null +++ b/docker/root/usr/bin/entrypoint @@ -0,0 +1,41 @@ +#!/bin/sh + +# Protect against buggy runc in docker <20.10.6 causing problems in with Alpine >= 3.14 +if [ ! -x /bin/sh ]; then + echo "Executable test for /bin/sh failed. Your Docker version is too old to run Alpine 3.14+ and Gitea. You must upgrade Docker."; + exit 1; +fi + +if [ "${USER}" != "git" ]; then + # Rename user + sed -i -e "s/^git\:/${USER}\:/g" /etc/passwd +fi + +if [ -z "${USER_GID}" ]; then + USER_GID="`id -g ${USER}`" +fi + +if [ -z "${USER_UID}" ]; then + USER_UID="`id -u ${USER}`" +fi + +# Change GID for USER? +if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "`id -g ${USER}`" ]; then + sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group + sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd +fi + +# Change UID for USER? +if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "`id -u ${USER}`" ]; then + sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd +fi + +for FOLDER in /data/gitea/conf /data/gitea/log /data/git /data/ssh; do + mkdir -p ${FOLDER} +done + +if [ $# -gt 0 ]; then + exec "$@" +else + exec /usr/bin/s6-svscan /etc/s6 +fi diff --git a/docker/root/usr/local/bin/environment-to-ini b/docker/root/usr/local/bin/environment-to-ini new file mode 100644 index 0000000..bb0c540 --- /dev/null +++ b/docker/root/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/docker/root/usr/local/bin/gitea b/docker/root/usr/local/bin/gitea new file mode 100644 index 0000000..3f78d65 --- /dev/null +++ b/docker/root/usr/local/bin/gitea @@ -0,0 +1,15 @@ +#!/bin/bash + +############################################################### +# This script sets defaults for gitea to run in the container # +############################################################### + +# It assumes that you place this script as gitea in /usr/local/bin +# +# And place the original in /usr/lib/gitea with working files in /data/gitea +GITEA="/app/gitea/gitea" +WORK_DIR="/data/gitea" +CUSTOM_PATH="/data/gitea" + +# Provide docker defaults +GITEA_WORK_DIR="${GITEA_WORK_DIR:-$WORK_DIR}" GITEA_CUSTOM="${GITEA_CUSTOM:-$CUSTOM_PATH}" exec -a "$0" "$GITEA" $CONF_ARG "$@" diff --git a/docker/rootless/etc/templates/app.ini b/docker/rootless/etc/templates/app.ini new file mode 100644 index 0000000..0357e6a --- /dev/null +++ b/docker/rootless/etc/templates/app.ini @@ -0,0 +1,57 @@ +APP_NAME = $APP_NAME +RUN_USER = $RUN_USER +RUN_MODE = $RUN_MODE + +[repository] +ROOT = $GITEA_WORK_DIR/git/repositories + +[repository.local] +LOCAL_COPY_PATH = $GITEA_TEMP/local-repo + +[repository.upload] +TEMP_PATH = $GITEA_TEMP/uploads + +[server] +APP_DATA_PATH = $GITEA_WORK_DIR +SSH_DOMAIN = $SSH_DOMAIN +HTTP_PORT = $HTTP_PORT +ROOT_URL = $ROOT_URL +DISABLE_SSH = $DISABLE_SSH +; In rootless gitea container only internal ssh server is supported +START_SSH_SERVER = true +SSH_PORT = $SSH_PORT +SSH_LISTEN_PORT = $SSH_LISTEN_PORT +BUILTIN_SSH_SERVER_USER = $RUN_USER +LFS_START_SERVER = $LFS_START_SERVER + +[database] +PATH = $GITEA_WORK_DIR/data/gitea.db +DB_TYPE = $DB_TYPE +HOST = $DB_HOST +NAME = $DB_NAME +USER = $DB_USER +PASSWD = $DB_PASSWD + +[session] +PROVIDER_CONFIG = $GITEA_WORK_DIR/data/sessions + +[picture] +AVATAR_UPLOAD_PATH = $GITEA_WORK_DIR/data/avatars +REPOSITORY_AVATAR_UPLOAD_PATH = $GITEA_WORK_DIR/data/repo-avatars + +[attachment] +PATH = $GITEA_WORK_DIR/data/attachments + +[log] +ROOT_PATH = $GITEA_WORK_DIR/data/log + +[security] +INSTALL_LOCK = $INSTALL_LOCK +SECRET_KEY = $SECRET_KEY + +[service] +DISABLE_REGISTRATION = $DISABLE_REGISTRATION +REQUIRE_SIGNIN_VIEW = $REQUIRE_SIGNIN_VIEW + +[lfs] +PATH = $GITEA_WORK_DIR/git/lfs diff --git a/docker/rootless/usr/local/bin/docker-entrypoint.sh b/docker/rootless/usr/local/bin/docker-entrypoint.sh new file mode 100755 index 0000000..4ffc4ff --- /dev/null +++ b/docker/rootless/usr/local/bin/docker-entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# Protect against buggy runc in docker <20.10.6 causing problems in with Alpine >= 3.14 +if [ ! -x /bin/sh ]; then + echo "Executable test for /bin/sh failed. Your Docker version is too old to run Alpine 3.14+ and Gitea. You must upgrade Docker."; + exit 1; +fi + +if [ -x /usr/local/bin/docker-setup.sh ]; then + /usr/local/bin/docker-setup.sh || { echo 'docker setup failed' ; exit 1; } +fi + +if [ $# -gt 0 ]; then + exec "$@" +else + exec /usr/local/bin/gitea -c "${GITEA_APP_INI}" web +fi diff --git a/docker/rootless/usr/local/bin/docker-setup.sh b/docker/rootless/usr/local/bin/docker-setup.sh new file mode 100755 index 0000000..3320d69 --- /dev/null +++ b/docker/rootless/usr/local/bin/docker-setup.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Prepare git folder +mkdir -p "${HOME}" && chmod 0700 "${HOME}" +if [ ! -w "${HOME}" ]; then echo "${HOME} is not writable"; exit 1; fi + +# Prepare custom folder +mkdir -p "${GITEA_CUSTOM}" && chmod 0700 "${GITEA_CUSTOM}" + +# Prepare temp folder +mkdir -p "${GITEA_TEMP}" && chmod 0700 "${GITEA_TEMP}" +if [ ! -w "${GITEA_TEMP}" ]; then echo "${GITEA_TEMP} is not writable"; exit 1; fi + +#Prepare config file +if [ ! -f "${GITEA_APP_INI}" ]; then + + #Prepare config file folder + GITEA_APP_INI_DIR=$(dirname "${GITEA_APP_INI}") + mkdir -p "${GITEA_APP_INI_DIR}" && chmod 0700 "${GITEA_APP_INI_DIR}" + if [ ! -w "${GITEA_APP_INI_DIR}" ]; then echo "${GITEA_APP_INI_DIR} is not writable"; exit 1; fi + + # Set INSTALL_LOCK to true only if SECRET_KEY is not empty and + # INSTALL_LOCK is empty + if [ -n "$SECRET_KEY" ] && [ -z "$INSTALL_LOCK" ]; then + INSTALL_LOCK=true + fi + + # Substitute the environment variables in the template + APP_NAME=${APP_NAME:-"Gitea: Git with a cup of tea"} \ + RUN_MODE=${RUN_MODE:-"prod"} \ + RUN_USER=${USER:-"git"} \ + SSH_DOMAIN=${SSH_DOMAIN:-"localhost"} \ + HTTP_PORT=${HTTP_PORT:-"3000"} \ + ROOT_URL=${ROOT_URL:-""} \ + DISABLE_SSH=${DISABLE_SSH:-"false"} \ + SSH_PORT=${SSH_PORT:-"2222"} \ + SSH_LISTEN_PORT=${SSH_LISTEN_PORT:-} \ + DB_TYPE=${DB_TYPE:-"sqlite3"} \ + DB_HOST=${DB_HOST:-"localhost:3306"} \ + DB_NAME=${DB_NAME:-"gitea"} \ + DB_USER=${DB_USER:-"root"} \ + DB_PASSWD=${DB_PASSWD:-""} \ + INSTALL_LOCK=${INSTALL_LOCK:-"false"} \ + DISABLE_REGISTRATION=${DISABLE_REGISTRATION:-"false"} \ + REQUIRE_SIGNIN_VIEW=${REQUIRE_SIGNIN_VIEW:-"false"} \ + SECRET_KEY=${SECRET_KEY:-""} \ + envsubst < /etc/templates/app.ini > "${GITEA_APP_INI}" +fi + +# Replace app.ini settings with env variables in the form GITEA__SECTION_NAME__KEY_NAME +environment-to-ini --config "${GITEA_APP_INI}" diff --git a/docker/rootless/usr/local/bin/environment-to-ini b/docker/rootless/usr/local/bin/environment-to-ini new file mode 100644 index 0000000..bb0c540 --- /dev/null +++ b/docker/rootless/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/docker/rootless/usr/local/bin/gitea b/docker/rootless/usr/local/bin/gitea new file mode 100644 index 0000000..9a9a569 --- /dev/null +++ b/docker/rootless/usr/local/bin/gitea @@ -0,0 +1,40 @@ +#!/bin/bash + +############################################################### +# This script sets defaults for gitea to run in the container # +############################################################### + +# It assumes that you place this script as gitea in /usr/local/bin +# +# And place the original in /usr/lib/gitea with working files in /data/gitea +GITEA="/app/gitea/gitea" +WORK_DIR="/var/lib/gitea" +APP_INI="/etc/gitea/app.ini" + +APP_INI_SET="" +for i in "$@"; do + case "$i" in + "-c") + APP_INI_SET=1 + ;; + "-c="*) + APP_INI_SET=1 + ;; + "--config") + APP_INI_SET=1 + ;; + "--config="*) + APP_INI_SET=1 + ;; + *) + ;; + esac +done + +if [ -z "$APP_INI_SET" ]; then + CONF_ARG=("-c" "${GITEA_APP_INI:-$APP_INI}") +fi + + +# Provide docker defaults +GITEA_WORK_DIR="${GITEA_WORK_DIR:-$WORK_DIR}" exec -a "$0" "$GITEA" "${CONF_ARG[@]}" "$@" diff --git a/docs/build-setup.md b/docs/build-setup.md new file mode 100644 index 0000000..d1cd465 --- /dev/null +++ b/docs/build-setup.md @@ -0,0 +1,67 @@ +# Setup and requirements + +This document lists the tools you need to build Gitea from source and how to get +the code. Once your environment is ready, see [development.md](development.md) for +the build and development workflow, and [testing.md](testing.md) for running tests. + +For the contribution workflow and review process, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Requirements + +### Go + +[Install Go](https://go.dev/doc/install) and set up your Go environment. The +required version is the one declared in [`go.mod`](../go.mod); installing the same +version your continuous integration uses avoids `gofmt` differences between Go +releases. + +> [!NOTE] +> Some `make` tasks build external Go tools on demand (for example `make +> watch-backend`). To use them, the `"$GOPATH"/bin` directory must be on your +> executable `PATH`; otherwise you have to manage those tools yourself. + +### Node.js and pnpm + +[Install Node.js](https://nodejs.org/en/download/) to build the JavaScript and CSS +files. The minimum supported version is the one declared in +[`package.json`](../package.json) (`engines.node`); the latest LTS is recommended. + +Gitea manages frontend dependencies with [pnpm](https://pnpm.io/). The `make` +targets invoke it for you, so installing pnpm manually is only needed if you want +to run `pnpm` commands directly. + +### Make + +Gitea uses [Make](https://www.gnu.org/software/make/) to drive builds, linting, and +tests. On Windows it can be installed via [MSYS2](https://www.msys2.org/) or +[Chocolatey](https://chocolatey.org/packages/make). + +### Python with uv (optional) + +Linting the templates, workflow files, and YAML requires Python tooling that Gitea +runs through [uv](https://docs.astral.sh/uv/). After installing uv, `make` creates +the environment automatically (`uv sync`); you only need this if you run +`make lint-templates`, `make lint-yaml`, or `make lint-actions` locally. + +### Git LFS + +The integration tests require [Git LFS](https://git-lfs.com/) to be installed. + +## Getting the source code + +Clone the repository: + +```bash +git clone https://github.com/go-gitea/gitea +``` + +To contribute changes, [fork the repository](https://github.com/go-gitea/gitea) on +GitHub and add your fork as a git remote so you can push branches and open pull +requests. See GitHub's [working with forks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks) +documentation for the details. + +## Installing dependencies + +Most build and test targets install the dependencies they need on their own. To +fetch everything up front, run `make deps` (or the per-group `make deps-frontend`, +`make deps-backend`, `make deps-tools`, `make deps-py`). diff --git a/docs/build-source.md b/docs/build-source.md new file mode 100644 index 0000000..4355857 --- /dev/null +++ b/docs/build-source.md @@ -0,0 +1,92 @@ +# Prepare build environment + +Complete the steps in [build-setup.md](build-setup.md) to prepare your environment for building Gitea from source. + +## Choose a branch + +By default, the cloned repository is on main branch (the current development branch for next major release, aka: main nightly). + +You can switch to a versioned branch (the branch for the next minor stable release, aka: stable nightly ) +or a versioned tag (matches the official releases with version numbers) + +To test a Pull Request, you can fetch its code by its Pull Request number (take `PR #123456` as example): + +```bash +git fetch origin pull/123456/head:pr-123456 +``` + +# Build + +Various [make tasks](https://github.com/go-gitea/gitea/blob/main/Makefile) +are provided to keep the build process as simple as possible. + +Depending on requirements, the following build tags can be included. + +- `bindata`: Build a single monolithic binary, with all assets included. Required for distribution and production build. +- `pam`: Enable support for PAM (Linux Pluggable Authentication Modules). + Can be used to authenticate local users or extend authentication to methods available to PAM. +- `gogit`: (EXPERIMENTAL) Use go-git variants of Git commands. + +To include all assets, use the `bindata` tag: + +```bash +TAGS="bindata" make build +``` + +Tag `gogit` is used to try to resolve some Windows-specific performance problems, POSIX systems don't need it. +You can build a Windows binary by: + +```bash +GOOS=windows TAGS="bindata gogit" make build +``` + +## Changing default paths + +Gitea will search for a number of things from the _`CustomPath`_. +By default, this is the `custom/` directory in the current working directory when running Gitea. +It will also look for its configuration file _`CustomConf`_ in `$(CustomPath)/conf/app.ini`, +and will use the current working directory as the relative base path _`AppWorkPath`_. + +These values, although useful when developing, may conflict with downstream users preferences. + +For packagers who need to use paths like `/etc/gitea/app.ini`, +they should define these values at build time for `make build` by environment variable like +`LDFLAGS='-X "module.Var1=Value1" -X "module.Var2=Value2"' TAGS="bindata" make build`. + +- _`CustomConf`_: `-X "gitea.dev/modules/setting.CustomConf=/etc/gitea/app.ini"` +- _`AppWorkPath`_: `-X "gitea.dev/modules/setting.AppWorkPath=/var/lib/gitea"` +- _`CustomPath`_: `-X "gitea.dev/modules/setting.CustomPath=/var/lib/gitea/custom"` +- Default PID file location: `-X "gitea.dev/cmd.PIDFile=/run/gitea.pid"` + +Add as many of the strings with their preceding `-X` to the `LDFLAGS` variable and run `make build` +with the appropriate `TAGS` as above. + +Running `gitea help` will allow you to review what the computed settings will be for your `gitea`. + +## Cross Build + +Gitea use's Golang's toolchain variables for cross-building. + +For example, to cross build for Linux ARM64: + +``` +GOOS=linux GOARCH=arm64 TAGS="bindata" make build +``` + +### Adding shell autocompletion + +Shell completion can be generated directly from binary with: +```sh +gitea completion +``` + +Supported values for `` are `bash`, `fish`, `pwsh` and `zsh`. +Details on how to load the completion for your shell can be found in the completion command help. + +## Source Maps + +By default, gitea generates reduced source maps for frontend files to conserve space. This can be controlled with the `ENABLE_SOURCEMAP` environment variable: + +- `ENABLE_SOURCEMAP=true` generates all source maps, the default for development builds +- `ENABLE_SOURCEMAP=reduced` generates limited source maps, the default for production builds +- `ENABLE_SOURCEMAP=false` generates no source maps diff --git a/docs/community-governance.md b/docs/community-governance.md new file mode 100644 index 0000000..da252a8 --- /dev/null +++ b/docs/community-governance.md @@ -0,0 +1,263 @@ +# Community governance and review process + +This document describes maintainer expectations, project governance, and the detailed pull request review workflow (labels, merge queue, commit message format for mergers). For what contributors should do when opening and updating a PR, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Table of contents + +- [Community governance and review process](#community-governance-and-review-process) + - [Table of contents](#table-of-contents) + - [Code review](#code-review) + - [Milestone](#milestone) + - [Labels](#labels) + - [Reviewing PRs](#reviewing-prs) + - [For reviewers](#for-reviewers) + - [Getting PRs merged](#getting-prs-merged) + - [Final call](#final-call) + - [Commit messages](#commit-messages) + - [PR Co-authors](#pr-co-authors) + - [PRs targeting `main`](#prs-targeting-main) + - [Backport PRs](#backport-prs) + - [Contribution Roles](#contribution-roles) + - [Maintainers](#maintainers) + - [Review expectations](#review-expectations) + - [Becoming a maintainer](#becoming-a-maintainer) + - [Stepping down, advisors, and inactivity](#stepping-down-advisors-and-inactivity) + - [Account security](#account-security) + - [Mergers](#mergers) + - [Becoming a merger](#becoming-a-merger) + - [Technical Oversight Committee (TOC)](#technical-oversight-committee-toc) + - [TOC election process](#toc-election-process) + - [Current TOC members](#current-toc-members) + - [Previous TOC/owners members](#previous-tocowners-members) + - [Governance Compensation](#governance-compensation) + - [Roadmap](#roadmap) + +## Code review + +### Milestone + +A PR should only be assigned to a milestone if it will likely be merged into the given version. \ +PRs without a milestone may not be merged. + +### Labels + +Almost all labels used inside Gitea can be classified as one of the following: + +- `modifies/…`: Determines which parts of the codebase are affected. These labels will be set through the CI. +- `topic/…`: Determines the conceptual component of Gitea that is affected, i.e. issues, projects, or authentication. At best, PRs should only target one component but there might be overlap. Must be set manually. +- `type/…`: Determines the type of an issue or PR (feature, refactoring, docs, bug, …). If GitHub supported scoped labels, these labels would be exclusive, so you should set **exactly** one, not more or less (every PR should fall into one of the provided categories, and only one). +- `issue/…` / `lgtm/…`: Labels that are specific to issues or PRs respectively and that are only necessary in a given context, i.e. `issue/not-a-bug` or `lgtm/need 2` + +Every PR should be labeled correctly with every label that applies. + +There are also some labels that will be managed automatically.\ +In particular, these are + +- the amount of pending required approvals +- has all `backport`s or needs a manual backport + +### Reviewing PRs + +Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest. + +#### For reviewers + +- **Verification**: Verify that the PR accurately reflects the changes, and verify that the tests and documentation are complete and aligned with the implementation. +- **Actionable feedback**: Say what should change and why, and distinguish required changes from optional suggestions. +- **Feedback**: Focus feedback on the issue itself and avoid comments about the contributor's abilities. +- **Request changes**: If you request changes (i.e., block a PR), give a clear rationale and, whenever possible, a concrete path to resolution. +- **Approval**: Only approve a PR when you are fully satisfied with its current state - "rubber-stamp" approvals need to be highlighted as such. + +### Getting PRs merged + +Changes to Gitea must be reviewed before they are accepted, including changes from owners and maintainers. The exception is critical bugs that prevent Gitea from compiling or starting. + +We require two maintainer approvals for every PR. When that is satisfied, your PR gets the `lgtm/done` label. After that, you mainly fix merge conflicts and respond to or implement maintainer requests; maintainers drive getting the PR merged. + +If a PR has `lgtm/done`, no open discussions, and no merge conflicts, any maintainer may add `reviewed/wait-merge`. That puts the PR in the merge queue. PRs are merged from the queue in the order of this list: + + + +Gitea uses its own tool, , to automate parts of the review process. The backporter: + +- Creates a backport PR when needed after the initial PR merges. +- Removes the PR from the merge queue after it merges. +- Keeps the oldest branch in the merge queue up to date with merges. + +### Final call + +If a PR has been ignored for more than 7 days with no comments or reviews, and the author or any maintainer believes it will not survive a long wait (such as a refactoring PR), they can send "final call" to the TOC by mentioning them in a comment. + +After another 7 days, if there is still zero approval, this is considered a polite refusal, and the PR will be closed to avoid wasting further time. Therefore, the "final call" has a cost, and should be used cautiously. + +However, if there are no objections from maintainers, the PR can be merged with only one approval from the TOC (not the author). + +### Commit messages + +Mergers are required to rewrite the PR title and the first comment (the summary) when necessary so the squash commit message is clear. +Usually the Pull Request description and commit message body should not be empty, unless the title is already clear enough or the description would be a copy of the comments in code. + +The final commit message: + +- should match the code changes. +- should only keep true co-authors, false-positive co-authors should be removed. +- should not hedge: replace phrases like `hopefully, won't happen anymore` with definite wording. +- should not contain hidden information like `` or extra information after the description's divider `----`. +- should not contain unrelated contents (e.g.: Release Notes, Configuration, etc.) from a Renovate update PR. + +#### PR Co-authors + +A person counts as a PR co-author once they (co-)authored a commit that is not simply a `Merge base branch into branch` commit. Mergers must remove such false-positive co-authors when writing the squash message. Every true co-author must remain in the commit message. + +#### PRs targeting `main` + +The commit message of PRs targeting `main` is always + +```bash +$PR_TITLE ($PR_INDEX) + +$REWRITTEN_PR_SUMMARY +``` + +#### Backport PRs + +The commit message of backport PRs is always + +```bash +$PR_TITLE ($INITIAL_PR_INDEX) ($BACKPORT_PR_INDEX) + +$REWRITTEN_PR_SUMMARY +``` + +## Contribution Roles + +### Maintainers + +We list [maintainers](../MAINTAINERS) so every PR gets proper review. + +#### Review expectations + +Every PR **must** be reviewed by at least two maintainers (or owners) before merge. **Exception:** after one week, refactoring PRs and documentation-only PRs need only one maintainer approval. + +Maintainers are expected to spend time on code reviews. + +#### Becoming a maintainer + +A maintainer should already be a Gitea contributor with at least four merged PRs. To apply, use the [Discord](https://discord.gg/Gitea) `#develop` channel. Maintainer teams may also invite contributors. + +#### Stepping down, advisors, and inactivity + +If you cannot keep reviewing, apply to leave the maintainers team. You can join the [advisors team](https://github.com/orgs/go-gitea/teams/advisors); advisors who want to review again are welcome back as maintainers. + +If a maintainer is inactive for more than three months and has not left the team, owners may move them to the advisors team. + +#### Account security + +For security, maintainers should enable 2FA and sign commits with GPG when possible: + +- [Two-factor authentication](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication) +- [Signing commits with GPG](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) + +Any account with write access (including bots and TOC members) **must** use [2FA](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). + +### Mergers + +Mergers are the maintainers who carry out the final merge of approved PRs. Their responsibilities, described throughout this guide, are: + +- Merging PRs from the [merge queue](#getting-prs-merged) in order, once a PR has `lgtm/done`, no open discussions, and no merge conflicts. +- Rewriting the PR title and description prior to the merge, making the [commit message](#commit-messages) clear.\ + In particular, mergers should edit the PR description.\ + Mergers should **not** edit the actual commit message except to remove unnecessary information. Because of that, even if users are looking at the PR, they can understand what changed. +- Assigning the correct labels (including `type/…`) needed for changelog and backport decisions. +- Agreeing, together with the owners, on when a release is ready (see [release management](release-management.md)). +- Merging a PR also means the PR looks good to the merger and is approved by the merger. + +If a merger violates these merge guides more than 3 times in the past 365 days +(e.g.: merge with unresolved reviews without TOC decision to ignore the review, merge with garbage commit messages), +they may lose their merging privileges for at least three months. + +#### Becoming a merger + +A merger must already be a Gitea maintainer. +To apply, use the [Discord](https://discord.gg/Gitea) `#maintainers` channel. +The minimum requirement for applications to become a merger is to have participated actively in the community for at least four months before applying. +Ultimately, regardless of previous participation, you can only become a merger if the TOC votes in your favor. + +You may also be invited by the TOC to become a merger. + +### Technical Oversight Committee (TOC) + +At the start of 2023, the `Owners` team was dissolved. Instead, the governance charter proposed a technical oversight committee (TOC) which expands the ownership team of the Gitea project from three elected positions to six positions. Three positions are elected as it has been over the past years, and the other three consist of appointed members from the Gitea company. +https://blog.gitea.com/quarterly-23q1/ + +#### TOC election process + +Any maintainer is eligible to be part of the community TOC if they are not associated with the Gitea company. +A maintainer can either nominate themselves, or can be nominated by other maintainers to be a candidate for the TOC election. +If you are nominated by someone else, you must first accept your nomination before the vote starts to be a candidate. + +The TOC is elected for one year, the TOC election happens yearly. +After the announcement of the results of the TOC election, elected members have two weeks time to confirm or refuse the seat. +If an elected member does not answer within this timeframe, they are automatically assumed to refuse the seat. +Refusals result in the person with the next highest vote getting the same choice. +As long as seats are empty in the TOC, members of the previous TOC can fill them until an elected member accepts the seat. + +If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration. + +If multiple persons have the same amount of votes, a random draw will be used to determine the order of the candidates with the same amount of votes, and thus who gets the seat first. +The candidates will be placed in the list in an alphabetical insensitive order by their username. +We use this script to determine the order of candidates with the same amount of votes: + +```python +import random +random.seed("Gitea TOC Election") +random.choice([, , ...]) +``` + +The result of this script needs then to be published in the TOC election issue to ensure transparency of the process. + +### Current TOC members + +- 2026-06-14 ~ 2026-12-31 + - Company + - [Yu Tian](https://gitea.com/Zettat123) + - [Lunny Xiao](https://gitea.com/lunny) + - [Matti Ranta](https://gitea.com/techknowlogick) + - Community + - [bircni](https://gitea.com/bircni) + - [delvh](https://gitea.com/delvh) + - [TheFox0x7](https://gitea.com/TheFox0x7) + + +### Previous TOC/owners members + +Here's the history of the owners and the time they served: + +- [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017 +- [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017 +- [Lauris Bukšis](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), 2025 +- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [6543](https://gitea.com/6543) - 2023, 2025 +- [John Olheiser](https://gitea.com/jolheiser) - 2023, 2024 +- [Jason Song](https://gitea.com/wolfogre) - 2023, 2025 + +## Governance Compensation + +Each member of the community elected TOC will be granted $500 each month as compensation for their work. + +Furthermore, any community release manager for a specific release or LTS will be compensated $500 for the delivery of said release. + +These funds will come from community sources like the OpenCollective rather than directly from the company. +Only non-company members are eligible for this compensation, and if a member of the community TOC takes the responsibility of release manager, they would only be compensated for their TOC duties. +Gitea Ltd employees are not eligible to receive any funds from the OpenCollective unless it is reimbursement for a purchase made for the Gitea project itself. + +## Roadmap + +Each year a roadmap will be discussed with the entire Gitea maintainers team, and feedback will be solicited from various stakeholders. +TOC members need to review the roadmap every year and work together on the direction of the project. + +When a vote is required for a proposal or other change, the vote of community elected TOC members count slightly more than the vote of company elected TOC members. With this approach, we both avoid ties and ensure that changes align with the mission statement and community opinion. + +You can visit our roadmap on the wiki. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..88a0380 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,141 @@ +# Development + +This document describes how to build Gitea from source and the day-to-day +development workflow. For prerequisites and how to obtain the code, see +[build-setup.md](build-setup.md). For running tests, see [testing.md](testing.md). For the +contribution workflow and review process, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +Area-specific guidelines: + +- [Backend development guidelines](guidelines-backend.md) +- [Frontend development guidelines](guidelines-frontend.md) +- [Refactoring guidelines](guidelines-refactoring.md) + +## Building + +To build Gitea for development, run: + +```bash +make build +``` + +No build tags are required: SQLite support is compiled in by default, which is +enough for local development. The `build` target runs two sub-targets, `frontend` +and `backend`. The `bindata` tag embeds the frontend assets into the binary and is +only needed when packaging a self-contained build, so leave it out during +development. + +See `make help` for all available targets, and the workflows in +[`.github/workflows`](https://github.com/go-gitea/gitea/tree/main/.github/workflows) +to see how continuous integration builds and checks Gitea. + +## Building continuously + +To rebuild automatically when source files change: + +```bash +# watch both frontend and backend +make watch + +# or watch only the frontend (starts the Vite dev server) +make watch-frontend + +# or watch only the backend (Go) +make watch-backend +``` + +Watching all backend source files may hit the default open-files limit on macOS or +Linux; raise it with `ulimit -n 12288` for the current shell, or in your shell +startup file to make it permanent. + +## Formatting, linting and checks + +Continuous integration rejects pull requests that fail formatting, linting, or +consistency checks. Format your code first: + +```bash +make fmt +``` + +Then lint: + +```bash +# lint everything +make lint +# or only one side +make lint-backend +make lint-frontend +``` + +Many linters can fix issues automatically with `make lint-fix` (or the scoped +`make lint-backend-fix` / `make lint-frontend-fix`). The combined consistency +checks that CI runs are available as `make checks`. + +## Building and adding SVGs + +SVG icons are built with `make svg`, which compiles the icon sources into +`public/assets/img/svg`. Custom icons can be added under `web_src/svg`. + +## Updating the API + +When you create or change API routes, you **must** update the +[Swagger](https://swagger.io/docs/specification/2-0/what-is-swagger/) documentation +using [go-swagger](https://goswagger.io/) comments. See the +[backend development guidelines](guidelines-backend.md) for how API routes, +request/response structs, and swagger definitions fit together. + +Regenerate and validate the spec after changing an endpoint, then commit the +updated JSON: + +```bash +make generate-swagger +make swagger-validate +``` + +CI verifies the committed spec is up to date with: + +```bash +make swagger-check +``` + +## Creating new configuration options + +When adding configuration options it is not enough to add them to the +`modules/setting` files. Also update +[`custom/conf/app.example.ini`](../custom/conf/app.example.ini), and document them in +the [configuration cheat sheet](https://docs.gitea.com/administration/config-cheat-sheet), +which lives in the [documentation repository](https://gitea.com/gitea/docs). + +## Database migrations + +If you make breaking changes to a database-persisted struct under `models/`, add a +new migration in `modelmigration/`. See [testing.md](testing.md#migration-tests) +for running the migration tests. + +## Testing + +For unit, integration, end-to-end, and migration tests, see [testing.md](testing.md). + +## IDE configuration + +### Visual Studio Code + +A `launch.json` and `tasks.json` are provided in +[`contrib/development/vscode`](../contrib/development/vscode). See +[`contrib/development/README.md`](../contrib/development/README.md) for details. + +### GoLand + +Clicking the `Run Application` arrow on `func main()` in `/main.go` starts a +debuggable Gitea instance. + +The `Output Directory` in `Run/Debug Configuration` **must** be set to the Gitea +project directory (the one containing `main.go` and `go.mod`). Otherwise the working +directory is a GoLand temporary directory, which prevents Gitea from loading dynamic +resources (such as templates) in development. + +## Submitting your changes + +Push your branch and open a pull request. See [CONTRIBUTING.md](../CONTRIBUTING.md) +for the review process and PR requirements. For help, join the `#Develop` channel on +[Discord](https://discord.gg/gitea). diff --git a/docs/guidelines-backend.md b/docs/guidelines-backend.md new file mode 100644 index 0000000..87c67b2 --- /dev/null +++ b/docs/guidelines-backend.md @@ -0,0 +1,150 @@ +# Backend development guidelines + +This document covers backend-specific architecture and contribution expectations. +For the general workflow see [CONTRIBUTING.md](../CONTRIBUTING.md), and for building +and testing see [development.md](development.md) and [testing.md](testing.md). + +## Background + +The backend is written in Go. Web routing is handled by +[chi](https://github.com/go-chi/chi) and database access goes through the +[XORM](https://xorm.io/) ORM. Understanding how the packages depend on each other is +essential before contributing backend code. + +## Package design + +### Package layout + +The backend is split into top-level packages, each with a focused responsibility: + +- `build`: helper scripts used at compile time +- `cmd`: subcommands such as `web`, `serv`, `hooks`, `doctor`, and admin utilities +- `models`: data structures and database operations (XORM); keeps external + dependencies to a minimum + - `models/db`: core database operations + - `models/fixtures`: sample data used by tests +- `modelmigration`: schema migration scripts +- `modules`: standalone functionality with few dependencies + - `modules/setting`: configuration handling + - `modules/git`: interaction with the Git command line +- `routers`: request handlers, split into `api`, `web`, `install`, and `private` +- `services`: business logic that ties routers and models together +- `templates`: Go HTML templates +- `public`: compiled frontend assets +- `tests`: integration and end-to-end test helpers + +### Dependency direction + +Dependencies only flow in one direction: + +```text +cmd → routers → services → models → modules +``` + +A package on the left may import a package on its right, but never the reverse. + +### Naming conventions + +- Top-level packages use the plural form: `services`, `models`, `routers`. +- Subpackages use the singular form: `services/user`, `models/repository`. + +When packages from different layers share a name, use a snake_case import alias to +disambiguate: + +```go +import user_service "gitea.dev/services/user" +``` + +### Database transactions + +Operations that must roll back together should run inside `db.WithTx()` (or +`db.WithTx2()` when a value must be returned), defined in `models/db/context.go`. +Functions that participate in a transaction take a `context.Context` as their first +parameter so the transaction can be propagated. + +### XORM gotchas + +- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates + every row in the table. +- Partial table migrations must use `SyncWithOptions(IgnoreDrop...)` rather than a + plain `Sync`. +- When inserting rows with preset IDs, MSSQL requires `SET IDENTITY_INSERT` to be + enabled and PostgreSQL requires the sequence to be updated afterwards. + +## Dependencies + +Go dependencies are managed with [Go Modules](https://go.dev/ref/mod). + +Pull requests should only modify `go.mod` and `go.sum` where it relates to the +change at hand, be it a bug fix or a new feature. Otherwise, these files should only +be touched by pull requests whose sole purpose is updating dependencies. Run +`make tidy` after any change to `go.mod`. + +Any `go.mod` / `go.sum` update must be justified in the PR description and must be +verified by reviewers and the merger to reference an existing upstream commit. + +## Golang HTML template + +Gitea uses Go's built-in HTML template engine which is dynamically & weakly typed. +To make template code maintainable: + +- Go code should take over complex logic and prepare template data as much as possible, templates only render the data. +- Prefer struct types provided by Go code instead of map types for template data. +- Avoid using single word names for non-local variables. +- Avoid passing `"root" $` or `"." .` to sub-templates, instead pass the specific data needed by the sub-template. +- Use explicit variable names instead of `.` to access data: ``{{range $item := $.TargetItems}}{{ $item.Name }}{{end}}`` +- Use Go code to implement render helpers if the render logic is too complex. + +Using a modern and statically & strongly typed template engine to replace Golang HTML template might be good, +but the challenge is huge, the requisites are: + +- A working and maintainable proof-of-concept for the most complex templates (like "PR View" and "PR Diff"). +- A firm commitment of sufficient engineering resources. + +## API v1 + +The API is documented with [Swagger](https://gitea.com/api/swagger) and is modelled +on [the GitHub API](https://docs.github.com/en/rest). + +### GitHub API compatibility + +Gitea's API should use the same endpoints and fields as the GitHub API where +possible, unless there is a good reason to deviate. + +- If Gitea offers functionality GitHub does not, a new endpoint may be added. +- If Gitea exposes information the GitHub API does not, a new field may be added as + long as it does not collide with a GitHub field. +- Existing fields should not be removed unless there is a strong reason; the same + applies to status responses. + +If you notice a problem that would require a breaking change, leave a comment in the +code for a future refactor to API v2 (which is currently not planned) rather than +breaking v1. + +### Adding and maintaining API routes + +- All possible results (errors, success, and failure messages) must be documented in + the swagger comments on the route. +- Every JSON request body must be defined as a struct in `modules/structs/` and + registered in [`routers/api/v1/swagger/options.go`](../routers/api/v1/swagger/options.go). +- Every JSON response must be defined as a struct in `modules/structs/` and + registered with its category under [`routers/api/v1/swagger/`](../routers/api/v1/swagger). + +### HTTP methods and status codes + +In general, choose HTTP methods as follows: + +- **GET** returns the requested object(s) with status **200 OK**. +- **POST** creates a new object (e.g. a user) and returns **201 Created** with the + created object. +- **PUT** adds or assigns an existing object (e.g. a user to a team) and returns + **204 No Content** with no response body. +- **PATCH** edits an existing object and returns the changed object with **200 OK**. +- **DELETE** removes an object and returns **204 No Content** with no body. + +### Requirements for API routes + +- All parameters of endpoints that edit an object must be optional, except those + needed to identify the object, which are required. +- Endpoints returning lists must support pagination (`page` and `limit` query + options) and set the `X-Total-Count` header via `ctx.SetTotalCountHeader(...)`. diff --git a/docs/guidelines-frontend.md b/docs/guidelines-frontend.md new file mode 100644 index 0000000..68b3348 --- /dev/null +++ b/docs/guidelines-frontend.md @@ -0,0 +1,101 @@ +# Frontend development guidelines + +This document covers frontend-specific architecture and contribution expectations. +For the general workflow see [CONTRIBUTING.md](../CONTRIBUTING.md), and for building +and testing see [development.md](development.md) and [testing.md](testing.md). + +## Background + +The frontend uses [Vue 3](https://vuejs.org/), hard-forked Fomantic-UI (built on jQuery) +and [Tailwind CSS](https://tailwindcss.com/). Pages are rendered with Go HTML templates. +Source files live in: + +- `web_src/css/`: CSS styles +- `web_src/js/`: JavaScript and TypeScript +- `web_src/js/components/`: Vue components +- `web_src/js/features/`: feature modules wired up at page load +- `templates/`: Go HTML templates + +## Dependencies + +Frontend dependencies are managed with [pnpm](https://pnpm.io/). The same rules as +for [backend dependencies](guidelines-backend.md#dependencies) apply, except the +relevant files are `package.json` and `pnpm-lock.yaml`, and new versions must always +reference an existing published version. + +## Framework usage + +Mixing frameworks arbitrarily makes code hard to maintain. Recommended combinations: + +- Vue3 +- Vanilla JavaScript +- Fomantic-UI (jQuery), deprecated, we vendored a specific version with a lot of changes. + +Avoid combinations such as Vue with Fomantic-UI. +Vue components may reuse Fomantic-UI CSS classes for visual consistency. +Use Go templates for simple or SEO-relevant pages and Vue for complex, interactive pages. +Gitea uses Vue 3 **without** JSX to keep HTML and JavaScript separate. + +> [!NOTE] +> Fomantic-UI is not an accessibility-friendly framework. Gitea patches some ARIA +> behavior, but accessibility work is ongoing — prefer semantic HTML and test +> keyboard/screen-reader behavior where you can. + +## Gitea-specific conventions + +- Keep features in their own files or directories. +- Use kebab-case for HTML `id`s and classes with 2-3 feature keywords. +- Prefix classes to avoid short-name conflicts between different frameworks. +- Our framework can automatically link "input" and "label" if they are the children of a `.field` element, + no need to write `id`/`for` attributes for them unless there are reasons to do so. +- Create a new class name when overriding framework styles instead of editing the framework's own classes, + or fix the framework's source to fix all cases. +- Prefer semantic elements such as `