diff --git a/.air.toml b/.air.toml index d13f8c4f995..de97bd8b298 100644 --- a/.air.toml +++ b/.air.toml @@ -8,6 +8,15 @@ delay = 1000 include_ext = ["go", "tmpl"] include_file = ["main.go"] include_dir = ["cmd", "models", "modules", "options", "routers", "services"] -exclude_dir = ["modules/git/tests", "services/gitdiff/testdata", "modules/avatar/testdata", "models/fixtures", "models/migrations/fixtures", "modules/migration/file_format_testdata", "modules/avatar/identicon/testdata"] +exclude_dir = [ + "models/fixtures", + "models/migrations/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 diff --git a/.changelog.yml b/.changelog.yml index 657dfa1c0ee..bfdee0c0ca2 100644 --- a/.changelog.yml +++ b/.changelog.yml @@ -13,46 +13,42 @@ groups: - name: BREAKING labels: - - kind/breaking + - pr/breaking - name: SECURITY labels: - - kind/security + - topic/security - name: FEATURES labels: - - kind/feature + - type/feature - name: API labels: - - kind/api + - modifies/api - name: ENHANCEMENTS labels: - - kind/enhancement - - kind/refactor - - kind/ui + - type/enhancement + - type/refactoring + - topic/ui - name: BUGFIXES labels: - - kind/bug + - type/bug - name: TESTING labels: - - kind/testing - - - name: TRANSLATION - labels: - - kind/translation + - type/testing - name: BUILD labels: - - kind/build - - kind/lint + - topic/build + - topic/code-linting - name: DOCS labels: - - kind/docs + - type/docs - name: MISC default: true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9e290fb6a51..d391cf78cf7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,14 +1,16 @@ { "name": "Gitea DevContainer", - "image": "mcr.microsoft.com/devcontainers/go:1.21-bullseye", + "image": "mcr.microsoft.com/devcontainers/go:1.22-bullseye", "features": { // installs nodejs into container "ghcr.io/devcontainers/features/node:1": { - "version":"20" + "version": "20" }, "ghcr.io/devcontainers/features/git-lfs:1.1.0": {}, "ghcr.io/devcontainers-contrib/features/poetry:2": {}, - "ghcr.io/devcontainers/features/python:1": {} + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + } }, "customizations": { "vscode": { @@ -22,7 +24,7 @@ "DavidAnson.vscode-markdownlint", "Vue.volar", "ms-azuretools.vscode-docker", - "zixuanchen.vitest-explorer", + "vitest.explorer", "qwtel.sqlite-viewer", "GitHub.vscode-pull-request-github" ] diff --git a/.dockerignore b/.dockerignore index 80cbeb040cf..b299c7313d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,7 +14,7 @@ _test # MS VSCode .vscode -__debug_bin +__debug_bin* # Architecture specific extensions/prefixes *.[568vq] @@ -62,7 +62,6 @@ cpu.out /data /indexers /log -/public/img/avatar /tests/integration/gitea-integration-* /tests/integration/indexers-* /tests/e2e/gitea-e2e-* @@ -78,7 +77,7 @@ cpu.out /public/assets/js /public/assets/css /public/assets/fonts -/public/assets/img/webpack +/public/assets/img/avatar /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 30843517700..00000000000 --- a/.drone.yml +++ /dev/null @@ -1,425 +0,0 @@ ---- -kind: pipeline -name: release-version - -platform: - os: linux - arch: amd64 - -workspace: - base: /source - path: / - -trigger: - event: - - tag - -volumes: - - name: deps - temp: {} - -steps: - - name: fetch-tags - image: docker:git - pull: always - commands: - - git fetch --tags --force - - - name: deps-frontend - image: node:20 - pull: always - commands: - - make deps-frontend - - - name: deps-backend - image: gitea/test_env:linux-1.20-amd64 - pull: always - commands: - - make deps-backend - volumes: - - name: deps - path: /go - - - name: static - image: techknowlogick/xgo:go-1.21.x - pull: always - commands: - - curl -sL https://deb.nodesource.com/setup_20.x | bash - && apt-get -qqy install nodejs - - export PATH=$PATH:$GOPATH/bin - - make release - environment: - GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not - TAGS: bindata sqlite sqlite_unlock_notify - DEBIAN_FRONTEND: noninteractive - depends_on: [fetch-tags] - volumes: - - name: deps - path: /go - - - name: gpg-sign - image: plugins/gpgsign:1 - pull: always - settings: - detach_sign: true - excludes: - - "dist/release/*.sha256" - files: - - "dist/release/*" - environment: - GPGSIGN_KEY: - from_secret: gpgsign_key - GPGSIGN_PASSPHRASE: - from_secret: gpgsign_passphrase - depends_on: [static] - - - name: release-tag - image: woodpeckerci/plugin-s3:latest - pull: always - settings: - acl: - from_secret: aws_s3_acl - region: - from_secret: aws_s3_region - bucket: - from_secret: aws_s3_bucket - endpoint: - from_secret: aws_s3_endpoint - path_style: - from_secret: aws_s3_path_style - source: "dist/release/*" - strip_prefix: dist/release/ - target: "/gitea/${DRONE_TAG##v}" - environment: - AWS_ACCESS_KEY_ID: - from_secret: aws_access_key_id - AWS_SECRET_ACCESS_KEY: - from_secret: aws_secret_access_key - depends_on: [gpg-sign] - - - name: github - image: plugins/github-release:latest - pull: always - settings: - files: - - "dist/release/*" - file_exists: overwrite - environment: - GITHUB_TOKEN: - from_secret: github_token - depends_on: [gpg-sign] - ---- -kind: pipeline -type: docker -name: docker-linux-amd64-release-version - -platform: - os: linux - arch: amd64 - -trigger: - ref: - include: - - "refs/tags/**" - exclude: - - "refs/tags/**-rc*" - paths: - exclude: - - "docs/**" - -steps: - - name: fetch-tags - image: docker:git - pull: always - commands: - - git fetch --tags --force - - - name: publish - image: plugins/docker:latest - pull: always - settings: - auto_tag: true - auto_tag_suffix: linux-amd64 - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - - - name: publish-rootless - image: plugins/docker:latest - settings: - dockerfile: Dockerfile.rootless - auto_tag: true - auto_tag_suffix: linux-amd64-rootless - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request ---- - -kind: pipeline -type: docker -name: docker-linux-amd64-release-candidate-version - -platform: - os: linux - arch: amd64 - -trigger: - ref: - - "refs/tags/**-rc*" - paths: - exclude: - - "docs/**" - -steps: - - name: fetch-tags - image: docker:git - pull: always - commands: - - git fetch --tags --force - - - name: publish - image: plugins/docker:latest - pull: always - settings: - tags: ${DRONE_TAG##v}-linux-amd64 - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - - - name: publish-rootless - image: plugins/docker:latest - settings: - dockerfile: Dockerfile.rootless - tags: ${DRONE_TAG##v}-linux-amd64-rootless - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - ---- -kind: pipeline -type: docker -name: docker-linux-arm64-release-version - -platform: - os: linux - arch: arm64 - -trigger: - ref: - include: - - "refs/tags/**" - exclude: - - "refs/tags/**-rc*" - paths: - exclude: - - "docs/**" - -steps: - - name: fetch-tags - image: docker:git - pull: always - commands: - - git fetch --tags --force - - - name: publish - image: plugins/docker:latest - pull: always - settings: - auto_tag: true - auto_tag_suffix: linux-arm64 - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - - - name: publish-rootless - image: plugins/docker:latest - settings: - dockerfile: Dockerfile.rootless - auto_tag: true - auto_tag_suffix: linux-arm64-rootless - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - ---- -kind: pipeline -type: docker -name: docker-linux-arm64-release-candidate-version - -platform: - os: linux - arch: arm64 - -trigger: - ref: - - "refs/tags/**-rc*" - paths: - exclude: - - "docs/**" - -steps: - - name: fetch-tags - image: docker:git - pull: always - commands: - - git fetch --tags --force - - - name: publish - image: plugins/docker:latest - pull: always - settings: - tags: ${DRONE_TAG##v}-linux-arm64 - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - - - name: publish-rootless - image: plugins/docker:latest - settings: - dockerfile: Dockerfile.rootless - tags: ${DRONE_TAG##v}-linux-arm64-rootless - repo: gitea/gitea - build_args: - - GOPROXY=https://goproxy.io - password: - from_secret: docker_password - username: - from_secret: docker_username - environment: - PLUGIN_MIRROR: - from_secret: plugin_mirror - DOCKER_BUILDKIT: 1 - when: - event: - exclude: - - pull_request - ---- -kind: pipeline -type: docker -name: docker-manifest-version - -platform: - os: linux - arch: amd64 - -steps: - - name: manifest-rootless - image: plugins/manifest - pull: always - settings: - auto_tag: true - ignore_missing: true - spec: docker/manifest.rootless.tmpl - password: - from_secret: docker_password - username: - from_secret: docker_username - - - name: manifest - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: docker/manifest.tmpl - password: - from_secret: docker_password - username: - from_secret: docker_username - -trigger: - ref: - - "refs/tags/**" - paths: - exclude: - - "docs/**" - -depends_on: - - docker-linux-amd64-release-version - - docker-linux-amd64-release-candidate-version - - docker-linux-arm64-release-version - - docker-linux-arm64-release-candidate-version diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 846823abc77..5fd0a245f22 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -3,6 +3,7 @@ reportUnusedDisableDirectives: true ignorePatterns: - /web_src/js/vendor + - /web_src/fomantic parserOptions: sourceType: module @@ -10,15 +11,18 @@ parserOptions: plugins: - "@eslint-community/eslint-plugin-eslint-comments" + - "@stylistic/eslint-plugin-js" - eslint-plugin-array-func - - eslint-plugin-custom-elements - - eslint-plugin-import + - eslint-plugin-github + - eslint-plugin-i - eslint-plugin-jquery - eslint-plugin-no-jquery - eslint-plugin-no-use-extend-native - eslint-plugin-regexp - eslint-plugin-sonarjs - eslint-plugin-unicorn + - eslint-plugin-vitest + - eslint-plugin-vitest-globals - eslint-plugin-wc env: @@ -39,13 +43,65 @@ overrides: worker: true rules: no-restricted-globals: [2, addEventListener, blur, close, closed, confirm, defaultStatus, defaultstatus, error, event, external, find, focus, frameElement, frames, history, innerHeight, innerWidth, isFinite, isNaN, length, locationbar, menubar, moveBy, moveTo, name, onblur, onerror, onfocus, onload, onresize, onunload, open, opener, opera, outerHeight, outerWidth, pageXOffset, pageYOffset, parent, print, removeEventListener, resizeBy, resizeTo, screen, screenLeft, screenTop, screenX, screenY, scroll, scrollbars, scrollBy, scrollTo, scrollX, scrollY, status, statusbar, stop, toolbar, top] - - files: ["build/generate-images.js"] - rules: - import/no-unresolved: [0] - import/no-extraneous-dependencies: [0] - files: ["*.config.*"] rules: - import/no-unused-modules: [0] + i/no-unused-modules: [0] + - files: ["**/*.test.*", "web_src/js/test/setup.js"] + env: + vitest-globals/env: true + rules: + vitest/consistent-test-filename: [0] + vitest/consistent-test-it: [0] + vitest/expect-expect: [0] + vitest/max-expects: [0] + vitest/max-nested-describe: [0] + vitest/no-alias-methods: [0] + vitest/no-commented-out-tests: [0] + vitest/no-conditional-expect: [0] + vitest/no-conditional-in-test: [0] + vitest/no-conditional-tests: [0] + vitest/no-disabled-tests: [0] + vitest/no-done-callback: [0] + vitest/no-duplicate-hooks: [0] + vitest/no-focused-tests: [0] + vitest/no-hooks: [0] + vitest/no-identical-title: [2] + vitest/no-interpolation-in-snapshots: [0] + vitest/no-large-snapshots: [0] + vitest/no-mocks-import: [0] + vitest/no-restricted-matchers: [0] + vitest/no-restricted-vi-methods: [0] + vitest/no-standalone-expect: [0] + vitest/no-test-prefixes: [0] + vitest/no-test-return-statement: [0] + vitest/prefer-called-with: [0] + vitest/prefer-comparison-matcher: [0] + vitest/prefer-each: [0] + vitest/prefer-equality-matcher: [0] + vitest/prefer-expect-resolves: [0] + vitest/prefer-hooks-in-order: [0] + vitest/prefer-hooks-on-top: [2] + vitest/prefer-lowercase-title: [0] + vitest/prefer-mock-promise-shorthand: [0] + vitest/prefer-snapshot-hint: [0] + vitest/prefer-spy-on: [0] + vitest/prefer-strict-equal: [0] + vitest/prefer-to-be: [0] + vitest/prefer-to-be-falsy: [0] + vitest/prefer-to-be-object: [0] + vitest/prefer-to-be-truthy: [0] + vitest/prefer-to-contain: [0] + vitest/prefer-to-have-length: [0] + vitest/prefer-todo: [0] + vitest/require-hook: [0] + vitest/require-to-throw-message: [0] + vitest/require-top-level-describe: [0] + vitest/valid-describe-callback: [2] + vitest/valid-expect: [2] + vitest/valid-title: [2] + - files: ["web_src/js/modules/fetch.js", "web_src/js/standalone/**/*"] + rules: + no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] rules: "@eslint-community/eslint-comments/disable-enable-pair": [2] @@ -57,11 +113,74 @@ rules: "@eslint-community/eslint-comments/no-unused-enable": [2] "@eslint-community/eslint-comments/no-use": [0] "@eslint-community/eslint-comments/require-description": [0] + "@stylistic/js/array-bracket-newline": [0] + "@stylistic/js/array-bracket-spacing": [2, never] + "@stylistic/js/array-element-newline": [0] + "@stylistic/js/arrow-parens": [2, always] + "@stylistic/js/arrow-spacing": [2, {before: true, after: true}] + "@stylistic/js/block-spacing": [0] + "@stylistic/js/brace-style": [2, 1tbs, {allowSingleLine: true}] + "@stylistic/js/comma-dangle": [2, always-multiline] + "@stylistic/js/comma-spacing": [2, {before: false, after: true}] + "@stylistic/js/comma-style": [2, last] + "@stylistic/js/computed-property-spacing": [2, never] + "@stylistic/js/dot-location": [2, property] + "@stylistic/js/eol-last": [2] + "@stylistic/js/function-call-spacing": [2, never] + "@stylistic/js/function-call-argument-newline": [0] + "@stylistic/js/function-paren-newline": [0] + "@stylistic/js/generator-star-spacing": [0] + "@stylistic/js/implicit-arrow-linebreak": [0] + "@stylistic/js/indent": [2, 2, {ignoreComments: true, SwitchCase: 1}] + "@stylistic/js/key-spacing": [2] + "@stylistic/js/keyword-spacing": [2] + "@stylistic/js/linebreak-style": [2, unix] + "@stylistic/js/lines-around-comment": [0] + "@stylistic/js/lines-between-class-members": [0] + "@stylistic/js/max-len": [0] + "@stylistic/js/max-statements-per-line": [0] + "@stylistic/js/multiline-ternary": [0] + "@stylistic/js/new-parens": [2] + "@stylistic/js/newline-per-chained-call": [0] + "@stylistic/js/no-confusing-arrow": [0] + "@stylistic/js/no-extra-parens": [0] + "@stylistic/js/no-extra-semi": [2] + "@stylistic/js/no-floating-decimal": [0] + "@stylistic/js/no-mixed-operators": [0] + "@stylistic/js/no-mixed-spaces-and-tabs": [2] + "@stylistic/js/no-multi-spaces": [2, {ignoreEOLComments: true, exceptions: {Property: true}}] + "@stylistic/js/no-multiple-empty-lines": [2, {max: 1, maxEOF: 0, maxBOF: 0}] + "@stylistic/js/no-tabs": [2] + "@stylistic/js/no-trailing-spaces": [2] + "@stylistic/js/no-whitespace-before-property": [2] + "@stylistic/js/nonblock-statement-body-position": [2] + "@stylistic/js/object-curly-newline": [0] + "@stylistic/js/object-curly-spacing": [2, never] + "@stylistic/js/object-property-newline": [0] + "@stylistic/js/one-var-declaration-per-line": [0] + "@stylistic/js/operator-linebreak": [2, after] + "@stylistic/js/padded-blocks": [2, never] + "@stylistic/js/padding-line-between-statements": [0] + "@stylistic/js/quote-props": [0] + "@stylistic/js/quotes": [2, single, {avoidEscape: true, allowTemplateLiterals: true}] + "@stylistic/js/rest-spread-spacing": [2, never] + "@stylistic/js/semi": [2, always, {omitLastInOneLineBlock: true}] + "@stylistic/js/semi-spacing": [2, {before: false, after: true}] + "@stylistic/js/semi-style": [2, last] + "@stylistic/js/space-before-blocks": [2, always] + "@stylistic/js/space-before-function-paren": [2, {anonymous: ignore, named: never, asyncArrow: always}] + "@stylistic/js/space-in-parens": [2, never] + "@stylistic/js/space-infix-ops": [2] + "@stylistic/js/space-unary-ops": [2] + "@stylistic/js/spaced-comment": [2, always] + "@stylistic/js/switch-colon-spacing": [2] + "@stylistic/js/template-curly-spacing": [2, never] + "@stylistic/js/template-tag-spacing": [2, never] + "@stylistic/js/wrap-iife": [2, inside] + "@stylistic/js/wrap-regex": [0] + "@stylistic/js/yield-star-spacing": [2, after] accessor-pairs: [2] - array-bracket-newline: [0] - array-bracket-spacing: [2, never] array-callback-return: [2, {checkForEach: true}] - array-element-newline: [0] array-func/avoid-reverse: [2] array-func/from-map: [2] array-func/no-unnecessary-this-arg: [2] @@ -69,117 +188,112 @@ rules: array-func/prefer-flat-map: [0] # handled by unicorn/prefer-array-flat-map array-func/prefer-flat: [0] # handled by unicorn/prefer-array-flat arrow-body-style: [0] - arrow-parens: [2, always] - arrow-spacing: [2, {before: true, after: true}] block-scoped-var: [2] - brace-style: [2, 1tbs, {allowSingleLine: true}] camelcase: [0] capitalized-comments: [0] class-methods-use-this: [0] - comma-dangle: [2, only-multiline] - comma-spacing: [2, {before: false, after: true}] - comma-style: [2, last] complexity: [0] - computed-property-spacing: [2, never] consistent-return: [0] consistent-this: [0] constructor-super: [2] curly: [0] - custom-elements/expose-class-on-global: [0] - custom-elements/extends-correct-class: [2] - custom-elements/file-name-matches-element: [2] - custom-elements/no-constructor: [2] - custom-elements/no-customized-built-in-elements: [2] - custom-elements/no-dom-traversal-in-attributechangedcallback: [2] - custom-elements/no-dom-traversal-in-connectedcallback: [2] - custom-elements/no-exports-with-element: [2] - custom-elements/no-method-prefixed-with-on: [2] - custom-elements/no-unchecked-define: [0] - custom-elements/one-element-per-file: [0] - custom-elements/tag-name-matches-class: [2] - custom-elements/valid-tag-name: [2] default-case-last: [2] default-case: [0] default-param-last: [0] - dot-location: [2, property] dot-notation: [0] - eol-last: [2] eqeqeq: [2] for-direction: [2] - func-call-spacing: [2, never] func-name-matching: [2] func-names: [0] func-style: [0] - function-call-argument-newline: [0] - function-paren-newline: [0] - generator-star-spacing: [0] getter-return: [2] + github/a11y-aria-label-is-well-formatted: [0] + github/a11y-no-title-attribute: [0] + github/a11y-no-visually-hidden-interactive-element: [0] + github/a11y-role-supports-aria-props: [0] + github/a11y-svg-has-accessible-name: [0] + github/array-foreach: [0] + github/async-currenttarget: [2] + github/async-preventdefault: [2] + github/authenticity-token: [0] + github/get-attribute: [0] + github/js-class-name: [0] + github/no-blur: [0] + github/no-d-none: [0] + github/no-dataset: [2] + github/no-dynamic-script-tag: [2] + github/no-implicit-buggy-globals: [2] + github/no-inner-html: [0] + github/no-innerText: [2] + github/no-then: [2] + github/no-useless-passive: [2] + github/prefer-observers: [2] + github/require-passive-events: [2] + github/unescaped-html-literal: [0] grouped-accessor-pairs: [2] guard-for-in: [0] id-blacklist: [0] id-length: [0] id-match: [0] - implicit-arrow-linebreak: [0] - import/consistent-type-specifier-style: [0] - import/default: [0] - import/dynamic-import-chunkname: [0] - import/export: [2] - import/exports-last: [0] - import/extensions: [2, always, {ignorePackages: true}] - import/first: [2] - import/group-exports: [0] - import/max-dependencies: [0] - import/named: [2] - import/namespace: [0] - import/newline-after-import: [0] - import/no-absolute-path: [0] - import/no-amd: [2] - import/no-anonymous-default-export: [0] - import/no-commonjs: [2] - import/no-cycle: [2, {ignoreExternal: true, maxDepth: 1}] - import/no-default-export: [0] - import/no-deprecated: [0] - import/no-dynamic-require: [0] - import/no-empty-named-blocks: [2] - import/no-extraneous-dependencies: [2] - import/no-import-module-exports: [0] - import/no-internal-modules: [0] - import/no-mutable-exports: [0] - import/no-named-as-default-member: [0] - import/no-named-as-default: [2] - import/no-named-default: [0] - import/no-named-export: [0] - import/no-namespace: [0] - import/no-nodejs-modules: [0] - import/no-relative-packages: [0] - import/no-relative-parent-imports: [0] - import/no-restricted-paths: [0] - import/no-self-import: [2] - import/no-unassigned-import: [0] - import/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$", ^vitest/]}] - import/no-unused-modules: [2, {unusedExports: true}] - import/no-useless-path-segments: [2, {commonjs: true}] - import/no-webpack-loader-syntax: [2] - import/order: [0] - import/prefer-default-export: [0] - import/unambiguous: [0] - indent: [2, 2, {SwitchCase: 1}] + i/consistent-type-specifier-style: [0] + i/default: [0] + i/dynamic-import-chunkname: [0] + i/export: [2] + i/exports-last: [0] + i/extensions: [2, always, {ignorePackages: true}] + i/first: [2] + i/group-exports: [0] + i/max-dependencies: [0] + i/named: [2] + i/namespace: [0] + i/newline-after-import: [0] + i/no-absolute-path: [0] + i/no-amd: [2] + i/no-anonymous-default-export: [0] + i/no-commonjs: [2] + i/no-cycle: [2, {ignoreExternal: true, maxDepth: 1}] + i/no-default-export: [0] + i/no-deprecated: [0] + i/no-dynamic-require: [0] + i/no-empty-named-blocks: [2] + i/no-extraneous-dependencies: [2] + i/no-import-module-exports: [0] + i/no-internal-modules: [0] + i/no-mutable-exports: [0] + i/no-named-as-default-member: [0] + i/no-named-as-default: [2] + i/no-named-default: [0] + i/no-named-export: [0] + i/no-namespace: [0] + i/no-nodejs-modules: [0] + i/no-relative-packages: [0] + i/no-relative-parent-imports: [0] + i/no-restricted-paths: [0] + i/no-self-import: [2] + i/no-unassigned-import: [0] + i/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$", ^vitest/]}] + i/no-unused-modules: [2, {unusedExports: true}] + i/no-useless-path-segments: [2, {commonjs: true}] + i/no-webpack-loader-syntax: [2] + i/order: [0] + i/prefer-default-export: [0] + i/unambiguous: [0] init-declarations: [0] jquery/no-ajax-events: [2] - jquery/no-ajax: [0] + jquery/no-ajax: [2] jquery/no-animate: [2] - jquery/no-attr: [0] + jquery/no-attr: [2] jquery/no-bind: [2] jquery/no-class: [0] jquery/no-clone: [2] jquery/no-closest: [0] - jquery/no-css: [0] + jquery/no-css: [2] jquery/no-data: [0] jquery/no-deferred: [2] jquery/no-delegate: [2] jquery/no-each: [0] jquery/no-extend: [2] - jquery/no-fade: [0] + jquery/no-fade: [2] jquery/no-filter: [0] jquery/no-find: [0] jquery/no-global-eval: [2] @@ -190,15 +304,15 @@ rules: jquery/no-in-array: [2] jquery/no-is-array: [2] jquery/no-is-function: [2] - jquery/no-is: [0] + jquery/no-is: [2] jquery/no-load: [2] - jquery/no-map: [0] + jquery/no-map: [2] jquery/no-merge: [2] jquery/no-param: [2] jquery/no-parent: [0] jquery/no-parents: [0] jquery/no-parse-html: [2] - jquery/no-prop: [0] + jquery/no-prop: [2] jquery/no-proxy: [2] jquery/no-ready: [2] jquery/no-serialize: [2] @@ -214,27 +328,17 @@ rules: jquery/no-val: [0] jquery/no-when: [2] jquery/no-wrap: [2] - key-spacing: [2] - keyword-spacing: [2] line-comment-position: [0] - linebreak-style: [2, unix] - lines-around-comment: [0] - lines-between-class-members: [0] logical-assignment-operators: [0] max-classes-per-file: [0] max-depth: [0] - max-len: [0] max-lines-per-function: [0] max-lines: [0] max-nested-callbacks: [0] max-params: [0] - max-statements-per-line: [0] max-statements: [0] multiline-comment-style: [2, separate-lines] - multiline-ternary: [0] new-cap: [0] - new-parens: [2] - newline-per-chained-call: [0] no-alert: [0] no-array-constructor: [2] no-async-promise-executor: [0] @@ -246,7 +350,6 @@ rules: no-class-assign: [2] no-compare-neg-zero: [2] no-cond-assign: [2, except-parens] - no-confusing-arrow: [0] no-console: [1, {allow: [debug, info, warn, error]}] no-const-assign: [2] no-constant-binary-expression: [2] @@ -276,10 +379,7 @@ rules: no-extra-bind: [2] no-extra-boolean-cast: [2] no-extra-label: [0] - no-extra-parens: [0] - no-extra-semi: [2] no-fallthrough: [2] - no-floating-decimal: [0] no-func-assign: [2] no-global-assign: [2] no-implicit-coercion: [2] @@ -293,12 +393,12 @@ rules: no-irregular-whitespace: [2] no-iterator: [2] no-jquery/no-ajax-events: [2] - no-jquery/no-ajax: [0] + no-jquery/no-ajax: [2] no-jquery/no-and-self: [2] no-jquery/no-animate-toggle: [2] no-jquery/no-animate: [2] - no-jquery/no-append-html: [0] - no-jquery/no-attr: [0] + no-jquery/no-append-html: [2] + no-jquery/no-attr: [2] no-jquery/no-bind: [2] no-jquery/no-box-model: [2] no-jquery/no-browser: [2] @@ -310,7 +410,7 @@ rules: no-jquery/no-constructor-attributes: [2] no-jquery/no-contains: [2] no-jquery/no-context-prop: [2] - no-jquery/no-css: [0] + no-jquery/no-css: [2] no-jquery/no-data: [0] no-jquery/no-deferred: [2] no-jquery/no-delegate: [2] @@ -341,14 +441,14 @@ rules: no-jquery/no-is-numeric: [2] no-jquery/no-is-plain-object: [2] no-jquery/no-is-window: [2] - no-jquery/no-is: [0] + no-jquery/no-is: [2] no-jquery/no-jquery-constructor: [0] no-jquery/no-live: [2] no-jquery/no-load-shorthand: [2] no-jquery/no-load: [2] no-jquery/no-map-collection: [0] no-jquery/no-map-util: [2] - no-jquery/no-map: [0] + no-jquery/no-map: [2] no-jquery/no-merge: [2] no-jquery/no-node-name: [2] no-jquery/no-noop: [2] @@ -363,7 +463,7 @@ rules: no-jquery/no-parse-html: [2] no-jquery/no-parse-json: [2] no-jquery/no-parse-xml: [2] - no-jquery/no-prop: [0] + no-jquery/no-prop: [2] no-jquery/no-proxy: [2] no-jquery/no-ready-shorthand: [2] no-jquery/no-ready: [2] @@ -384,7 +484,7 @@ rules: no-jquery/no-visibility: [2] no-jquery/no-when: [2] no-jquery/no-wrap: [2] - no-jquery/variable-pattern: [0] + no-jquery/variable-pattern: [2] no-label-var: [2] no-labels: [0] # handled by no-restricted-syntax no-lone-blocks: [2] @@ -393,10 +493,7 @@ rules: no-loss-of-precision: [2] no-magic-numbers: [0] no-misleading-character-class: [2] - no-mixed-operators: [0] - no-mixed-spaces-and-tabs: [2] no-multi-assign: [0] - no-multi-spaces: [2, {ignoreEOLComments: true, exceptions: {Property: true}}] no-multi-str: [2] no-negated-condition: [0] no-nested-ternary: [0] @@ -420,7 +517,7 @@ rules: no-restricted-exports: [0] no-restricted-globals: [2, addEventListener, blur, close, closed, confirm, defaultStatus, defaultstatus, error, event, external, find, focus, frameElement, frames, history, innerHeight, innerWidth, isFinite, isNaN, length, location, locationbar, menubar, moveBy, moveTo, name, onblur, onerror, onfocus, onload, onresize, onunload, open, opener, opera, outerHeight, outerWidth, pageXOffset, pageYOffset, parent, print, removeEventListener, resizeBy, resizeTo, screen, screenLeft, screenTop, screenX, screenY, scroll, scrollbars, scrollBy, scrollTo, scrollX, scrollY, self, status, statusbar, stop, toolbar, top, __dirname, __filename] no-restricted-imports: [0] - no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] + no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression, {selector: "CallExpression[callee.name='fetch']", message: "use modules/fetch.js instead"}] no-return-assign: [0] no-script-url: [2] no-self-assign: [2, {props: true}] @@ -430,19 +527,17 @@ rules: no-shadow-restricted-names: [2] no-shadow: [0] no-sparse-arrays: [2] - no-tabs: [2] no-template-curly-in-string: [2] no-ternary: [0] no-this-before-super: [2] no-throw-literal: [2] - no-trailing-spaces: [2] no-undef-init: [2] no-undef: [2, {typeof: true}] no-undefined: [0] no-underscore-dangle: [0] no-unexpected-multiline: [2] no-unmodified-loop-condition: [2] - no-unneeded-ternary: [0] + no-unneeded-ternary: [2] no-unreachable-loop: [2] no-unreachable: [2] no-unsafe-finally: [2] @@ -465,33 +560,25 @@ rules: no-var: [2] no-void: [2] no-warning-comments: [0] - no-whitespace-before-property: [2] no-with: [0] # handled by no-restricted-syntax - nonblock-statement-body-position: [2] - object-curly-newline: [0] - object-curly-spacing: [2, never] object-shorthand: [2, always] one-var-declaration-per-line: [0] one-var: [0] operator-assignment: [2, always] operator-linebreak: [2, after] - padded-blocks: [2, never] - padding-line-between-statements: [0] prefer-arrow-callback: [2, {allowNamedFunctions: true, allowUnboundThis: true}] prefer-const: [2, {destructuring: all, ignoreReadBeforeAssign: true}] prefer-destructuring: [0] prefer-exponentiation-operator: [2] prefer-named-capture-group: [0] prefer-numeric-literals: [2] - prefer-object-has-own: [0] + prefer-object-has-own: [2] prefer-object-spread: [2] prefer-promise-reject-errors: [2, {allowEmptyReject: false}] prefer-regex-literals: [2] prefer-rest-params: [2] prefer-spread: [2] prefer-template: [2] - quote-props: [0] - quotes: [2, single, {avoidEscape: true, allowTemplateLiterals: true}] radix: [2, as-needed] regexp/confusing-quantifier: [2] regexp/control-character-escape: [2] @@ -508,6 +595,7 @@ rules: regexp/no-empty-character-class: [0] regexp/no-empty-group: [2] regexp/no-empty-lookarounds-assertion: [2] + regexp/no-empty-string-literal: [2] regexp/no-escape-backspace: [2] regexp/no-extra-lookaround-assertions: [0] regexp/no-invalid-regexp: [2] @@ -538,6 +626,8 @@ rules: regexp/no-useless-non-capturing-group: [2] regexp/no-useless-quantifier: [2] regexp/no-useless-range: [2] + regexp/no-useless-set-operand: [2] + regexp/no-useless-string-literal: [2] regexp/no-useless-two-nums-quantifier: [2] regexp/no-zero-quantifier: [2] regexp/optimal-lookaround-quantifier: [2] @@ -557,10 +647,12 @@ rules: regexp/prefer-regexp-exec: [2] regexp/prefer-regexp-test: [2] regexp/prefer-result-array-groups: [0] + regexp/prefer-set-operation: [2] regexp/prefer-star-quantifier: [2] regexp/prefer-unicode-codepoint-escapes: [2] regexp/prefer-w: [0] regexp/require-unicode-regexp: [0] + regexp/simplify-set-operations: [2] regexp/sort-alternatives: [0] regexp/sort-character-class-elements: [0] regexp/sort-flags: [0] @@ -571,10 +663,6 @@ rules: require-await: [0] require-unicode-regexp: [0] require-yield: [2] - rest-spread-spacing: [2, never] - semi-spacing: [2, {before: false, after: true}] - semi-style: [2, last] - semi: [2, always, {omitLastInOneLineBlock: true}] sonarjs/cognitive-complexity: [0] sonarjs/elseif-without-else: [0] sonarjs/max-switch-cases: [0] @@ -610,16 +698,8 @@ rules: sort-imports: [0] sort-keys: [0] sort-vars: [0] - space-before-blocks: [2, always] - space-in-parens: [2, never] - space-infix-ops: [2] - space-unary-ops: [2] - spaced-comment: [2, always] strict: [0] - switch-colon-spacing: [2] symbol-description: [2] - template-curly-spacing: [2, never] - template-tag-spacing: [2, never] unicode-bom: [2, never] unicorn/better-regex: [0] unicorn/catch-error-name: [0] @@ -636,12 +716,14 @@ rules: unicorn/import-style: [0] unicorn/new-for-builtins: [2] unicorn/no-abusive-eslint-disable: [0] + unicorn/no-anonymous-default-export: [0] unicorn/no-array-callback-reference: [0] unicorn/no-array-for-each: [2] unicorn/no-array-method-this-argument: [2] unicorn/no-array-push-push: [2] unicorn/no-array-reduce: [2] unicorn/no-await-expression-member: [0] + unicorn/no-await-in-promise-methods: [2] unicorn/no-console-spaces: [0] unicorn/no-document-cookie: [2] unicorn/no-empty-file: [2] @@ -658,11 +740,13 @@ rules: unicorn/no-null: [0] unicorn/no-object-as-default-parameter: [0] unicorn/no-process-exit: [0] + unicorn/no-single-promise-in-promise-methods: [2] unicorn/no-static-only-class: [2] unicorn/no-thenable: [2] unicorn/no-this-assignment: [2] unicorn/no-typeof-undefined: [2] unicorn/no-unnecessary-await: [2] + unicorn/no-unnecessary-polyfills: [2] unicorn/no-unreadable-array-destructuring: [0] unicorn/no-unreadable-iife: [2] unicorn/no-unused-properties: [2] @@ -737,15 +821,25 @@ rules: valid-typeof: [2, {requireStringLiterals: true}] vars-on-top: [0] wc/attach-shadow-constructor: [2] + wc/define-tag-after-class-definition: [0] + wc/expose-class-on-global: [0] + wc/file-name-matches-element: [2] + wc/guard-define-call: [0] wc/guard-super-call: [2] + wc/max-elements-per-file: [0] + wc/no-child-traversal-in-attributechangedcallback: [2] + wc/no-child-traversal-in-connectedcallback: [2] wc/no-closed-shadow-root: [2] wc/no-constructor-attributes: [2] wc/no-constructor-params: [2] - wc/no-invalid-element-name: [0] # covered by custom-elements/valid-tag-name + wc/no-constructor: [2] + wc/no-customized-built-in-elements: [2] + wc/no-exports-with-element: [0] + wc/no-invalid-element-name: [2] + wc/no-invalid-extends: [2] + wc/no-method-prefixed-with-on: [2] wc/no-self-class: [2] wc/no-typos: [2] wc/require-listener-teardown: [2] - wrap-iife: [2, inside] - wrap-regex: [0] - yield-star-spacing: [2, after] + wc/tag-name-matches-class: [2] yoda: [2, never] diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 624a2d97db1..1447a6ea322 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ open_collective: gitea -custom: https://www.bountysource.com/teams/gitea diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index c482affbbd4..94c1bd0ab70 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -1,6 +1,6 @@ name: Bug Report description: Found something you weren't expecting? Report it here! -labels: ["kind/bug"] +labels: ["type/bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 71aaa094233..3c9953019dd 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,6 +1,6 @@ name: Feature Request description: Got an idea for a feature that Gitea doesn't have currently? Submit your idea here! -labels: ["kind/proposal"] +labels: ["type/proposal"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/ui.bug-report.yaml b/.github/ISSUE_TEMPLATE/ui.bug-report.yaml index ef0a1014e58..387aee897b3 100644 --- a/.github/ISSUE_TEMPLATE/ui.bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/ui.bug-report.yaml @@ -1,6 +1,6 @@ name: Web Interface Bug Report description: Something doesn't look quite as it should? Report it here! -labels: ["kind/bug", "kind/ui"] +labels: ["type/bug", "topic/ui"] body: - type: markdown attributes: diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 24c80bc60a7..023fb05a296 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -2,3 +2,4 @@ self-hosted-runner: labels: - actuated-4cpu-8gb - actuated-4cpu-16gb + - nscloud diff --git a/.github/labeler.yml b/.github/labeler.yml index 06a5cd99d15..d1b4d00d802 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,35 +1,77 @@ -kind/docs: - - "**/*.md" - - "docs/**" - -kind/ui: - - "web_src/**/*" - - all: ["templates/**", "!templates/swagger/v1_json.tmpl"] - -kind/api: - - "templates/swagger/v1_json.tmpl" - - "routers/api/**" - -kind/build: - - "Makefile" - - "Dockerfile" - - "Dockerfile.rootless" - - "docker/**" - - "webpack.config.js" - -theme/package-registry: - - "modules/packages/**" - - "services/packages/**" - - "routers/api/packages/**" - - "routers/web/shared/packages/**" - -kind/cli: - - "cmd/**" - -kind/lint: - - ".eslintrc.yaml" - - ".golangci.yml" - - ".markdownlint.yaml" - - ".spectral.yaml" - - ".stylelintrc.yaml" - - ".yamllint.yaml" +modifies/docs: + - changed-files: + - any-glob-to-any-file: + - "**/*.md" + - "docs/**" + +modifies/templates: + - changed-files: + - all-globs-to-any-file: + - "templates/**" + - "!templates/swagger/v1_json.tmpl" + +modifies/api: + - changed-files: + - any-glob-to-any-file: + - "routers/api/**" + - "templates/swagger/v1_json.tmpl" + +modifies/cli: + - changed-files: + - any-glob-to-any-file: + - "cmd/**" + +modifies/translation: + - changed-files: + - any-glob-to-any-file: + - "options/locale/*.ini" + +modifies/migrations: + - changed-files: + - any-glob-to-any-file: + - "models/migrations/**" + +modifies/internal: + - changed-files: + - any-glob-to-any-file: + - ".air.toml" + - "Makefile" + - "Dockerfile" + - "Dockerfile.rootless" + - ".dockerignore" + - "docker/**" + - ".editorconfig" + - ".eslintrc.yaml" + - ".golangci.yml" + - ".gitpod.yml" + - ".markdownlint.yaml" + - ".spectral.yaml" + - "stylelint.config.js" + - ".yamllint.yaml" + - ".github/**" + - ".gitea/" + - ".devcontainer/**" + - "build.go" + - "build/**" + - "contrib/**" + +modifies/dependencies: + - changed-files: + - any-glob-to-any-file: + - "package.json" + - "package-lock.json" + - "pyproject.toml" + - "poetry.lock" + - "go.mod" + - "go.sum" + +modifies/go: + - changed-files: + - any-glob-to-any-file: + - "**/*.go" + +modifies/js: + - changed-files: + - any-glob-to-any-file: + - "**/*.js" + - "**/*.vue" diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index ebe95acf533..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Configuration for probot-stale - https://github.com/probot/stale - -# Number of days of inactivity before an Issue or Pull Request becomes stale -daysUntilStale: 60 - -# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. -# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. -daysUntilClose: 14 - -# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable -exemptLabels: - - status/blocked - - kind/security - - lgtm/done - - reviewed/confirmed - - priority/critical - - kind/proposal - -# Set to true to ignore issues in a project (defaults to false) -exemptProjects: false - -# Set to true to ignore issues in a milestone (defaults to false) -exemptMilestones: false - -# Label to use when marking as stale -staleLabel: stale - -# Comment to post when marking as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had recent activity. - I am here to help clear issues left open even if solved or waiting for more insight. - This issue will be closed if no further activity occurs during the next 2 weeks. - If the issue is still valid just add a comment to keep it alive. - Thank you for your contributions. - -# Comment to post when closing a stale Issue or Pull Request. -closeComment: > - This issue has been automatically closed because of inactivity. - You can re-open it if needed. - -# Limit the number of actions per hour, from 1-30. Default is 30 -limitPerRun: 1 - -# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': -pulls: - daysUntilStale: 60 - daysUntilClose: 60 - markComment: > - This pull request has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs during the next 2 months. Thank you - for your contributions. - closeComment: > - This pull request has been automatically closed because of inactivity. - You can re-open it if needed. diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index acdf7cd3646..cd8386ecc52 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -10,15 +10,15 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make generate-license generate-gitignore timeout-minutes: 40 - name: push translations to repo - uses: appleboy/git-push-action@v0.0.2 + uses: appleboy/git-push-action@v0.0.3 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/cron-lock.yml b/.github/workflows/cron-lock.yml deleted file mode 100644 index 935f926cce9..00000000000 --- a/.github/workflows/cron-lock.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: cron-lock - -on: - schedule: - - cron: "0 0 * * *" # every day at 00:00 UTC - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -concurrency: - group: lock - -jobs: - action: - runs-on: ubuntu-latest - if: github.repository == 'go-gitea/gitea' - steps: - - uses: dessant/lock-threads@v4 - with: - issue-inactive-days: 45 diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index 3f147c685d8..f1b51debf12 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -10,19 +10,24 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' steps: - - uses: actions/checkout@v3 - - name: download from crowdin - uses: docker://jonasfranz/crowdin + - uses: actions/checkout@v4 + - uses: crowdin/github-action@v1 + 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 }} - PLUGIN_DOWNLOAD: true - PLUGIN_EXPORT_DIR: options/locale/ - PLUGIN_IGNORE_BRANCH: true - PLUGIN_PROJECT_IDENTIFIER: gitea - name: update locales run: ./build/update-locales.sh - name: push translations to repo - uses: appleboy/git-push-action@v0.0.2 + uses: appleboy/git-push-action@v0.0.3 with: author_email: "teabot@gitea.io" author_name: GiteaBot @@ -31,19 +36,3 @@ jobs: commit_message: "[skip ci] Updated translations via Crowdin" remote: "git@github.com:go-gitea/gitea.git" ssh_key: ${{ secrets.DEPLOY_KEY }} - crowdin-push: - runs-on: ubuntu-latest - if: github.repository == 'go-gitea/gitea' - steps: - - uses: actions/checkout@v3 - - name: push translations to crowdin - uses: docker://jonasfranz/crowdin - env: - CROWDIN_KEY: ${{ secrets.CROWDIN_KEY }} - PLUGIN_UPLOAD: true - PLUGIN_EXPORT_DIR: options/locale/ - PLUGIN_IGNORE_BRANCH: true - PLUGIN_PROJECT_IDENTIFIER: gitea - PLUGIN_FILES: | - locale_en-US.ini: options/locale/locale_en-US.ini - PLUGIN_BRANCH: main diff --git a/.github/workflows/disk-clean.yml b/.github/workflows/disk-clean.yml new file mode 100644 index 00000000000..8abe8891c79 --- /dev/null +++ b/.github/workflows/disk-clean.yml @@ -0,0 +1,25 @@ +name: disk-clean + +on: + workflow_call: + +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + # this might remove tools that are actually needed, + # if set to "true" but frees about 6 GB + tool-cache: false + + # all of these default to true, but feel free to set to + # "false" if necessary for your workflow + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: false + swap-storage: true diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index 48db7a732e6..9a609e05515 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -34,8 +34,8 @@ jobs: swagger: ${{ steps.changes.outputs.swagger }} yaml: ${{ steps.changes.outputs.yaml }} steps: - - uses: actions/checkout@v3 - - uses: dorny/paths-filter@v2 + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 id: changes with: filters: | @@ -48,6 +48,7 @@ jobs: - "Makefile" - ".golangci.yml" - ".editorconfig" + - "options/locale/locale_en-US.ini" frontend: - "**/*.js" @@ -57,18 +58,22 @@ jobs: - "package-lock.json" - "Makefile" - ".eslintrc.yaml" - - ".stylelintrc.yaml" + - "stylelint.config.js" - ".npmrc" docs: - "**/*.md" - "docs/**" - ".markdownlint.yaml" + - "package.json" + - "package-lock.json" actions: - ".github/workflows/*" + - "Makefile" templates: + - "tools/lint-templates-*.js" - "templates/**/*.tmpl" - "pyproject.toml" - "poetry.lock" @@ -90,3 +95,5 @@ jobs: - "**/*.yml" - "**/*.yaml" - ".yamllint.yaml" + - "pyproject.toml" + - "poetry.lock" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index bcbd2188461..99a69ab1749 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -16,10 +16,10 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - run: make lint-backend @@ -31,12 +31,16 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: 20 - run: pip install poetry - run: make deps-py + - run: make deps-frontend - run: make lint-templates lint-yaml: @@ -44,10 +48,10 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - run: pip install poetry - run: make deps-py - run: make lint-yaml @@ -57,22 +61,34 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - run: make deps-frontend - run: make lint-swagger + lint-spell: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.templates == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - run: make lint-spell + lint-go-windows: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - run: make lint-go-windows lint-go-vet @@ -86,10 +102,10 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - run: make lint-go @@ -101,10 +117,10 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - run: make --always-make checks-backend # ensure the "go-licenses" make target runs @@ -114,8 +130,8 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - run: make deps-frontend @@ -129,10 +145,10 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true # no frontend build here as backend should be able to build # even without any frontend files @@ -161,8 +177,8 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 20 - run: make deps-frontend @@ -174,6 +190,9 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true - run: make lint-actions diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index bbe589d5c8d..61c03915096 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest services: pgsql: - image: postgres:15 + image: postgres:12 env: POSTGRES_DB: test POSTGRES_PASSWORD: postgres @@ -31,17 +31,17 @@ jobs: 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: bitnami/minio:2021.3.17 + image: bitnami/minio:2023.8.31 env: - MINIO_ACCESS_KEY: 123456 - MINIO_SECRET_KEY: 12345678 + MINIO_ROOT_USER: 123456 + MINIO_ROOT_PASSWORD: 12345678 ports: - "9000:9000" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts' @@ -49,7 +49,10 @@ jobs: - run: make backend env: TAGS: bindata - - run: make test-pgsql-migration test-pgsql + - name: run migration tests + run: make test-pgsql-migration + - name: run tests + run: make test-pgsql timeout-minutes: 50 env: TAGS: bindata gogit @@ -63,16 +66,19 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - run: make deps-backend - run: make backend env: TAGS: bindata gogit sqlite sqlite_unlock_notify - - run: make test-sqlite-migration test-sqlite + - name: run migration tests + run: make test-sqlite-migration + - name: run tests + run: make test-sqlite timeout-minutes: 50 env: TAGS: bindata gogit sqlite sqlite_unlock_notify @@ -85,13 +91,6 @@ jobs: needs: files-changed runs-on: ubuntu-latest services: - mysql: - image: mysql:5.7 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: true - MYSQL_DATABASE: test - ports: - - "3306:3306" elasticsearch: image: elasticsearch:7.5.0 env: @@ -104,13 +103,6 @@ jobs: MEILI_ENV: development # disable auth ports: - "7700:7700" - smtpimap: - image: tabascoterrier/docker-imap-devel:latest - ports: - - "25:25" - - "143:143" - - "587:587" - - "993:993" redis: image: redis options: >- # wait until redis has started @@ -128,10 +120,10 @@ jobs: ports: - "9000:9000" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts' @@ -152,16 +144,16 @@ jobs: RACE_ENABLED: true GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} - test-mysql5: + test-mysql: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest services: mysql: - image: mysql:5.7 + image: mysql:8.0 env: MYSQL_ALLOW_EMPTY_PASSWORD: true - MYSQL_DATABASE: test + MYSQL_DATABASE: testgitea ports: - "3306:3306" elasticsearch: @@ -178,10 +170,10 @@ jobs: - "587:587" - "993:993" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - 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' @@ -189,51 +181,23 @@ jobs: - run: make backend env: TAGS: bindata + - name: run migration tests + run: make test-mysql-migration - name: run tests - run: make test-mysql-migration integration-test-coverage + run: make integration-test-coverage env: TAGS: bindata RACE_ENABLED: true USE_REPO_TEST_DIR: 1 TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" - test-mysql8: - if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' - needs: files-changed - runs-on: ubuntu-latest - services: - mysql8: - image: mysql:8 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: true - MYSQL_DATABASE: testgitea - ports: - - "3306:3306" - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 - with: - go-version: "~1.21" - check-latest: true - - name: Add hosts to /etc/hosts - run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql8" | sudo tee -a /etc/hosts' - - run: make deps-backend - - run: make backend - env: - TAGS: bindata - - run: make test-mysql8-migration test-mysql8 - timeout-minutes: 50 - env: - TAGS: bindata - USE_REPO_TEST_DIR: 1 - test-mssql: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest services: mssql: - image: mcr.microsoft.com/mssql/server:latest + image: mcr.microsoft.com/mssql/server:2017-latest env: ACCEPT_EULA: Y MSSQL_PID: Standard @@ -241,10 +205,10 @@ jobs: ports: - "1433:1433" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql" | sudo tee -a /etc/hosts' @@ -252,7 +216,9 @@ jobs: - run: make backend env: TAGS: bindata - - run: make test-mssql-migration test-mssql + - run: make test-mssql-migration + - name: run tests + run: make test-mssql timeout-minutes: 50 env: TAGS: bindata diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index 61f1fd5632e..f74277de671 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -16,8 +16,8 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@v2 - - uses: docker/build-push-action@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v5 with: push: false tags: gitea/gitea:linux-amd64 @@ -27,8 +27,8 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: docker/setup-buildx-action@v2 - - uses: docker/build-push-action@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v5 with: push: false file: Dockerfile.rootless diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 7b950bfd380..5a249db9f8d 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -16,12 +16,12 @@ jobs: needs: files-changed runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 20 - run: make deps-frontend frontend deps-backend diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index c62142b9d28..812819b5991 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -9,13 +9,12 @@ concurrency: cancel-in-progress: true jobs: - label: + labeler: runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: - dot: true sync-labels: true diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 0671ecee8bc..80e6683919f 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -1,26 +1,28 @@ -name: release-nightly-assets +name: release-nightly on: push: - branches: [ main, release/v* ] + branches: [main, release/v*] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + disk-clean: + uses: ./.github/workflows/disk-clean.yml nightly-binary: - runs-on: actuated-4cpu-16gb + runs-on: nscloud steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 20 - run: make deps-frontend deps-backend @@ -30,7 +32,7 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify - name: import gpg key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v5 + uses: crazy-max/ghaction-import-gpg@v6 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -46,28 +48,28 @@ jobs: REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" + - name: configure aws + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: upload binaries to s3 - uses: jakejarvis/s3-sync-action@master - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_REGION: ${{ secrets.AWS_REGION }} - SOURCE_DIR: dist/release - DEST_DIR: gitea/${{ steps.clean_name.outputs.branch }} + run: | + aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress nightly-docker-rootful: - runs-on: actuated-4cpu-16gb + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - name: Get cleaned branch name id: clean_name run: | @@ -79,32 +81,32 @@ jobs: REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: fetch go modules run: make vendor - name: build rootful docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: gitea/gitea:${{ steps.clean_name.outputs.branch }} nightly-docker-rootless: - runs-on: actuated-4cpu-16gb + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: - go-version: "~1.21" + go-version-file: go.mod check-latest: true - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - name: Get cleaned branch name id: clean_name run: | @@ -116,14 +118,14 @@ jobs: REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: fetch go modules run: make vendor - name: build rootless docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml new file mode 100644 index 00000000000..12d1e1e4beb --- /dev/null +++ b/.github/workflows/release-tag-rc.yml @@ -0,0 +1,132 @@ +name: release-tag-rc + +on: + push: + tags: + - "v1*-rc*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + binary: + runs-on: nscloud + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: make deps-frontend deps-backend + # xgo build + - run: make release + env: + TAGS: bindata sqlite sqlite_unlock_notify + - name: import gpg key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPGSIGN_KEY }} + passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: sign binaries + run: | + for f in dist/release/*; do + echo '${{ secrets.GPGSIGN_PASSPHRASE }}' | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u ${{ steps.import_gpg.outputs.fingerprint }} --output "$f.asc" "$f" + done + # clean branch name to get the folder name in S3 + - name: Get cleaned branch name + id: clean_name + run: | + REF_NAME=$(echo "${{ github.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: configure aws + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: upload binaries to s3 + run: | + aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress + - name: Install GH CLI + uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + with: + gh-cli-version: 2.39.1 + - name: create github release + run: | + gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/* + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + docker-rootful: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/metadata-action@v5 + id: meta + with: + images: gitea/gitea + flavor: | + latest=false + # 1.2.3-rc0 + tags: | + type=semver,pattern={{version}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: build rootful docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + docker-rootless: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/metadata-action@v5 + id: meta + with: + images: 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}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: build rootless docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + file: Dockerfile.rootless + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml new file mode 100644 index 00000000000..e0e93633e8a --- /dev/null +++ b/.github/workflows/release-tag-version.yml @@ -0,0 +1,143 @@ +name: release-tag-version + +on: + push: + tags: + - "v1.*" + - "!v1*-rc*" + - "!v1*-dev" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + binary: + runs-on: nscloud + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: make deps-frontend deps-backend + # xgo build + - run: make release + env: + TAGS: bindata sqlite sqlite_unlock_notify + - name: import gpg key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPGSIGN_KEY }} + passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: sign binaries + run: | + for f in dist/release/*; do + echo '${{ secrets.GPGSIGN_PASSPHRASE }}' | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u ${{ steps.import_gpg.outputs.fingerprint }} --output "$f.asc" "$f" + done + # clean branch name to get the folder name in S3 + - name: Get cleaned branch name + id: clean_name + run: | + REF_NAME=$(echo "${{ github.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: configure aws + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: upload binaries to s3 + run: | + aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress + - name: Install GH CLI + uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + with: + gh-cli-version: 2.39.1 + - name: create github release + run: | + gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/* + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + docker-rootful: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/metadata-action@v5 + id: meta + with: + images: gitea/gitea + # this will generate tags in the following format: + # latest + # 1 + # 1.2 + # 1.2.3 + tags: | + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: build rootful docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + docker-rootless: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # fetch all commits instead of only the last as some branches are long lived and could have many between versions + # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 + - run: git fetch --unshallow --quiet --tags --force + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/metadata-action@v5 + id: meta + with: + images: 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={{major}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: build rootless docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + file: Dockerfile.rootless + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 53365ed0b4e..501fef7dcf8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,11 @@ _test .idea # Goland's output filename can not be set manually /go_build_* +/gitea_* # MS VSCode .vscode -__debug_bin +__debug_bin* *.cgo1.go *.cgo2.c @@ -57,7 +58,7 @@ cpu.out /data /indexers /log -/public/img/avatar +/public/assets/img/avatar /tests/integration/gitea-integration-* /tests/integration/indexers-* /tests/e2e/gitea-e2e-* @@ -76,7 +77,6 @@ cpu.out /public/assets/css /public/assets/fonts /public/assets/licenses.txt -/public/assets/img/webpack /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/.gitpod.yml b/.gitpod.yml index 35b22c45ae7..f573d55a767 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -10,10 +10,19 @@ tasks: - name: Run backend command: | gp sync-await setup - if [ ! -f custom/conf/app.ini ] - then + + # Get the URL and extract the domain + url=$(gp url 3000) + domain=$(echo $url | awk -F[/:] '{print $4}') + + if [ -f custom/conf/app.ini ]; then + sed -i "s|^ROOT_URL =.*|ROOT_URL = ${url}/|" custom/conf/app.ini + sed -i "s|^DOMAIN =.*|DOMAIN = ${domain}|" custom/conf/app.ini + sed -i "s|^SSH_DOMAIN =.*|SSH_DOMAIN = ${domain}|" custom/conf/app.ini + sed -i "s|^NO_REPLY_ADDRESS =.*|SSH_DOMAIN = noreply.${domain}|" custom/conf/app.ini + else mkdir -p custom/conf/ - echo -e "[server]\nROOT_URL=$(gp url 3000)/" > custom/conf/app.ini + echo -e "[server]\nROOT_URL = ${url}/" > custom/conf/app.ini echo -e "\n[database]\nDB_TYPE = sqlite3\nPATH = $GITPOD_REPO_ROOT/data/gitea.db" >> custom/conf/app.ini fi export TAGS="sqlite sqlite_unlock_notify" @@ -33,7 +42,7 @@ vscode: - DavidAnson.vscode-markdownlint - Vue.volar - ms-azuretools.vscode-docker - - zixuanchen.vitest-explorer + - vitest.explorer - qwtel.sqlite-viewer - GitHub.vscode-pull-request-github diff --git a/.golangci.yml b/.golangci.yml index 069dc13c991..d6ce37f49af 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,7 +29,6 @@ linters: fast: false run: - go: "1.21" timeout: 10m skip-dirs: - node_modules @@ -75,7 +74,6 @@ linters-settings: - name: modifies-value-receiver gofumpt: extra-rules: true - lang-version: "1.21" depguard: rules: main: diff --git a/.ignore b/.ignore index 5c945ab9810..5b96dabd38a 100644 --- a/.ignore +++ b/.ignore @@ -4,6 +4,8 @@ /modules/options/bindata.go /modules/public/bindata.go /modules/templates/bindata.go -/vendor +/options/gitignore +/options/license /public/assets +/vendor node_modules diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 7ccdd53e897..b251ff796cb 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -1,18 +1,15 @@ commands-show-output: false fenced-code-language: false first-line-h1: false -header-increment: 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-blanks-blockquote: false -no-duplicate-header: {allow_different_nesting: true} -no-emphasis-as-header: 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-punctuation: false no-trailing-spaces: {br_spaces: 0} single-h1: false diff --git a/.stylelintrc.yaml b/.stylelintrc.yaml deleted file mode 100644 index f30d79d71fe..00000000000 --- a/.stylelintrc.yaml +++ /dev/null @@ -1,221 +0,0 @@ -plugins: - - stylelint-declaration-strict-value - - stylelint-declaration-block-no-ignored-properties - - stylelint-stylistic - -ignoreFiles: - - "**/*.go" - -overrides: - - files: ["**/chroma/*", "**/codemirror/*", "**/standalone/*", "**/console.css", "font_i18n.css"] - rules: - scale-unlimited/declaration-strict-value: null - - files: ["**/chroma/*", "**/codemirror/*"] - rules: - block-no-empty: null - - files: ["**/*.vue"] - customSyntax: postcss-html - -rules: - alpha-value-notation: null - annotation-no-unknown: true - at-rule-allowed-list: null - at-rule-disallowed-list: null - at-rule-empty-line-before: null - at-rule-no-unknown: true - at-rule-no-vendor-prefix: true - at-rule-property-required-list: null - block-no-empty: true - color-function-notation: null - color-hex-alpha: null - color-hex-length: null - color-named: null - color-no-hex: null - color-no-invalid-hex: true - comment-empty-line-before: null - comment-no-empty: true - comment-pattern: null - comment-whitespace-inside: null - comment-word-disallowed-list: null - custom-media-pattern: null - custom-property-empty-line-before: null - custom-property-no-missing-var-function: true - custom-property-pattern: null - declaration-block-no-duplicate-custom-properties: true - declaration-block-no-duplicate-properties: [true, {ignore: [consecutive-duplicates-with-different-values]}] - declaration-block-no-redundant-longhand-properties: null - declaration-block-no-shorthand-property-overrides: null - declaration-block-single-line-max-declarations: null - declaration-empty-line-before: null - declaration-no-important: null - declaration-property-max-values: null - declaration-property-unit-allowed-list: null - declaration-property-unit-disallowed-list: {line-height: [em]} - declaration-property-value-allowed-list: null - declaration-property-value-disallowed-list: null - declaration-property-value-no-unknown: true - font-family-name-quotes: always-where-recommended - font-family-no-duplicate-names: true - font-family-no-missing-generic-family-keyword: true - font-weight-notation: null - function-allowed-list: null - function-calc-no-unspaced-operator: true - function-disallowed-list: null - function-linear-gradient-no-nonstandard-direction: true - function-name-case: lower - function-no-unknown: null - function-url-no-scheme-relative: null - function-url-quotes: always - function-url-scheme-allowed-list: null - function-url-scheme-disallowed-list: null - hue-degree-notation: null - import-notation: string - keyframe-block-no-duplicate-selectors: true - keyframe-declaration-no-important: true - keyframe-selector-notation: null - keyframes-name-pattern: null - length-zero-no-unit: true - max-nesting-depth: null - media-feature-name-allowed-list: null - media-feature-name-disallowed-list: null - media-feature-name-no-unknown: true - media-feature-name-no-vendor-prefix: true - media-feature-name-unit-allowed-list: null - media-feature-name-value-allowed-list: null - media-feature-name-value-no-unknown: true - media-feature-range-notation: null - media-query-no-invalid: true - named-grid-areas-no-invalid: true - no-descending-specificity: null - no-duplicate-at-import-rules: true - no-duplicate-selectors: true - no-empty-source: true - no-invalid-double-slash-comments: true - no-invalid-position-at-import-rule: null - no-irregular-whitespace: true - no-unknown-animations: null - no-unknown-custom-properties: null - number-max-precision: null - plugin/declaration-block-no-ignored-properties: true - property-allowed-list: null - property-disallowed-list: null - property-no-unknown: true - property-no-vendor-prefix: null - rule-empty-line-before: null - rule-selector-property-disallowed-list: null - scale-unlimited/declaration-strict-value: [[color, background-color, border-color, font-weight], {ignoreValues: /^(inherit|transparent|unset|initial|currentcolor|none)$/, ignoreFunctions: false, disableFix: true}] - selector-attribute-name-disallowed-list: null - selector-attribute-operator-allowed-list: null - selector-attribute-operator-disallowed-list: null - selector-attribute-quotes: always - selector-class-pattern: null - selector-combinator-allowed-list: null - selector-combinator-disallowed-list: null - selector-disallowed-list: null - selector-id-pattern: null - selector-max-attribute: null - selector-max-class: null - selector-max-combinators: null - selector-max-compound-selectors: null - selector-max-id: null - selector-max-pseudo-class: null - selector-max-specificity: null - selector-max-type: null - selector-max-universal: null - selector-nested-pattern: null - selector-no-qualifying-type: null - selector-no-vendor-prefix: true - selector-not-notation: null - selector-pseudo-class-allowed-list: null - selector-pseudo-class-disallowed-list: null - selector-pseudo-class-no-unknown: true - selector-pseudo-element-allowed-list: null - selector-pseudo-element-colon-notation: double - selector-pseudo-element-disallowed-list: null - selector-pseudo-element-no-unknown: true - selector-type-case: lower - selector-type-no-unknown: [true, {ignore: [custom-elements]}] - shorthand-property-no-redundant-values: true - string-no-newline: true - stylistic/at-rule-name-case: null - stylistic/at-rule-name-newline-after: null - stylistic/at-rule-name-space-after: null - stylistic/at-rule-semicolon-newline-after: null - stylistic/at-rule-semicolon-space-before: null - stylistic/block-closing-brace-empty-line-before: null - stylistic/block-closing-brace-newline-after: null - stylistic/block-closing-brace-newline-before: null - stylistic/block-closing-brace-space-after: null - stylistic/block-closing-brace-space-before: null - stylistic/block-opening-brace-newline-after: null - stylistic/block-opening-brace-newline-before: null - stylistic/block-opening-brace-space-after: null - stylistic/block-opening-brace-space-before: null - stylistic/color-hex-case: lower - stylistic/declaration-bang-space-after: never - stylistic/declaration-bang-space-before: null - stylistic/declaration-block-semicolon-newline-after: null - stylistic/declaration-block-semicolon-newline-before: null - stylistic/declaration-block-semicolon-space-after: null - stylistic/declaration-block-semicolon-space-before: never - stylistic/declaration-block-trailing-semicolon: null - stylistic/declaration-colon-newline-after: null - stylistic/declaration-colon-space-after: null - stylistic/declaration-colon-space-before: never - stylistic/function-comma-newline-after: null - stylistic/function-comma-newline-before: null - stylistic/function-comma-space-after: null - stylistic/function-comma-space-before: null - stylistic/function-max-empty-lines: 0 - stylistic/function-parentheses-newline-inside: never-multi-line - stylistic/function-parentheses-space-inside: null - stylistic/function-whitespace-after: null - stylistic/indentation: 2 - stylistic/linebreaks: null - stylistic/max-empty-lines: 1 - stylistic/max-line-length: null - stylistic/media-feature-colon-space-after: null - stylistic/media-feature-colon-space-before: never - stylistic/media-feature-name-case: null - stylistic/media-feature-parentheses-space-inside: null - stylistic/media-feature-range-operator-space-after: always - stylistic/media-feature-range-operator-space-before: always - stylistic/media-query-list-comma-newline-after: null - stylistic/media-query-list-comma-newline-before: null - stylistic/media-query-list-comma-space-after: null - stylistic/media-query-list-comma-space-before: null - stylistic/no-empty-first-line: null - stylistic/no-eol-whitespace: true - stylistic/no-extra-semicolons: true - stylistic/no-missing-end-of-source-newline: null - stylistic/number-leading-zero: null - stylistic/number-no-trailing-zeros: null - stylistic/property-case: lower - stylistic/selector-attribute-brackets-space-inside: null - stylistic/selector-attribute-operator-space-after: null - stylistic/selector-attribute-operator-space-before: null - stylistic/selector-combinator-space-after: null - stylistic/selector-combinator-space-before: null - stylistic/selector-descendant-combinator-no-non-space: null - stylistic/selector-list-comma-newline-after: null - stylistic/selector-list-comma-newline-before: null - stylistic/selector-list-comma-space-after: always-single-line - stylistic/selector-list-comma-space-before: never-single-line - stylistic/selector-max-empty-lines: 0 - stylistic/selector-pseudo-class-case: lower - stylistic/selector-pseudo-class-parentheses-space-inside: never - stylistic/selector-pseudo-element-case: lower - stylistic/string-quotes: double - stylistic/unicode-bom: null - stylistic/unit-case: lower - stylistic/value-list-comma-newline-after: null - stylistic/value-list-comma-newline-before: null - stylistic/value-list-comma-space-after: null - stylistic/value-list-comma-space-before: null - stylistic/value-list-max-empty-lines: 0 - time-min-milliseconds: null - unit-allowed-list: null - unit-disallowed-list: null - unit-no-unknown: true - value-keyword-case: null - value-no-vendor-prefix: [true, {ignoreValues: [box, inline-box]}] diff --git a/.yamllint.yaml b/.yamllint.yaml index c0fce7c301f..5a1e1e87515 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -24,8 +24,6 @@ rules: document-start: level: error present: false - ignore: | - /.drone.yml document-end: present: false @@ -44,5 +42,3 @@ rules: ignore: | .venv node_modules - /models/fixtures - /models/migrations/fixtures diff --git a/BSDmakefile b/BSDmakefile index 13174f8fd22..79696eadcf7 100644 --- a/BSDmakefile +++ b/BSDmakefile @@ -42,13 +42,13 @@ GARGS = "--no-print-directory" # The GNU convention is to use the lowercased `prefix` variable/macro to # specify the installation directory. Humor them. -GPREFIX = "" +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 + which $(GMAKE) || (printf "Error: GNU Make is required!\n\n" 1>&2 && false) .PHONY: FRC $(.TARGETS): FRC diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef677b88ca..e119d0bec01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,715 @@ This changelog goes through all the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.com). +## [1.21.6](https://github.com/go-gitea/gitea/releases/tag/v1.21.6) - 2024-02-22 + +* SECURITY + * Fix XSS vulnerabilities (#29336) + * Use general token signing secret (#29205) (#29325) +* ENHANCEMENTS + * Refactor git version functions and check compatibility (#29155) (#29157) + * Improve user experience for outdated comments (#29050) (#29086) + * Hide code links on release page if user cannot read code (#29064) (#29066) + * Wrap contained tags and branches again (#29021) (#29026) + * Fix incorrect button CSS usages (#29015) (#29023) + * Strip trailing newline in markdown code copy (#29019) (#29022) + * Implement some action notifier functions (#29173) (#29308) + * Load outdated comments when (un)resolving conversation on PR timeline (#29203) (#29221) +* BUGFIXES + * Refactor issue template parsing and fix API endpoint (#29069) (#29140) + * Fix swift packages not resolving (#29095) (#29102) + * Remove SSH workaround (#27893) (#29332) + * Only log error when tag sync fails (#29295) (#29327) + * Fix SSPI user creation (#28948) (#29323) + * Improve the `issue_comment` workflow trigger event (#29277) (#29322) + * Discard unread data of `git cat-file` (#29297) (#29310) + * Fix error display when merging PRs (#29288) (#29309) + * Prevent double use of `git cat-file` session. (#29298) (#29301) + * Fix missing link on outgoing new release notifications (#29079) (#29300) + * Fix debian InRelease Acquire-By-Hash newline (#29204) (#29299) + * Always write proc-receive hook for all git versions (#29287) (#29291) + * Do not show delete button when time tracker is disabled (#29257) (#29279) + * Workaround to clean up old reviews on creating a new one (#28554) (#29264) + * Fix bug when the linked account was disactived and list the linked accounts (#29263) + * Do not use lower tag names to find releases/tags (#29261) (#29262) + * Fix missed edit issues event for actions (#29237) (#29251) + * Only delete scheduled workflows when needed (#29091) (#29235) + * Make submit event code work with both jQuery event and native event (#29223) (#29234) + * Fix push to create with capitalize repo name (#29090) (#29206) + * Use ghost user if user was not found (#29161) (#29169) + * Dont load Review if Comment is CommentTypeReviewRequest (#28551) (#29160) + * Refactor parseSignatureFromCommitLine (#29054) (#29108) + * Avoid showing unnecessary JS errors when there are elements with different origin on the page (#29081) (#29089) + * Fix gitea-origin-url with default ports (#29085) (#29088) + * Fix orgmode link resolving (#29024) (#29076) + * Fix Elasticsearh Request Entity Too Large #28117 (#29062) (#29075) + * Do not render empty comments (#29039) (#29049) + * Avoid sending update/delete release notice when it is draft (#29008) (#29025) + * Fix gitea-action user avatar broken on edited menu (#29190) (#29307) + * Disallow merge when required checked are missing (#29143) (#29268) + * Fix incorrect link to swift doc and swift package-registry login command (#29096) (#29103) + * Convert visibility to number (#29226) (#29244) +* DOCS + * Remove outdated docs from some languages (#27530) (#29208) + * Fix typos in the documentation (#29048) (#29056) + * Explained where create issue/PR template (#29035) + +## [1.21.5](https://github.com/go-gitea/gitea/releases/tag/v1.21.5) - 2024-01-31 + +* SECURITY + * Prevent anonymous container access if `RequireSignInView` is enabled (#28877) (#28882) + * Update go dependencies and fix go-git (#28893) (#28934) +* BUGFIXES + * Revert "Speed up loading the dashboard on mysql/mariadb (#28546)" (#29006) (#29007) + * Fix an actions schedule bug (#28942) (#28999) + * Fix update enable_prune even if mirror_interval is not provided (#28905) (#28929) + * Fix uploaded artifacts should be overwritten (#28726) backport v1.21 (#28832) + * Preserve BOM in web editor (#28935) (#28959) + * Strip `/` from relative links (#28932) (#28952) + * Don't remove all mirror repository's releases when mirroring (#28817) (#28939) + * Implement `MigrateRepository` for the actions notifier (#28920) (#28923) + * Respect branch info for relative links (#28909) (#28922) + * Don't reload timeline page when (un)resolving or replying conversation (#28654) (#28917) + * Only migrate the first 255 chars of a Github issue title (#28902) (#28912) + * Fix sort bug on repository issues list (#28897) (#28901) + * Fix `DeleteCollaboration` transaction behaviour (#28886) (#28889) + * Fix schedule not trigger bug because matching full ref name with short ref name (#28874) (#28888) + * Fix migrate storage bug (#28830) (#28867) + * Fix archive creating LFS hooks and breaking pull requests (#28848) (#28851) + * Fix reverting a merge commit failing (#28794) (#28825) + * Upgrade xorm to v1.3.7 to fix a resource leak problem caused by Iterate (#28891) (#28895) + * Fix incorrect PostgreSQL connection string for Unix sockets (#28865) (#28870) +* ENHANCEMENTS + * Make loading animation less aggressive (#28955) (#28956) + * Avoid duplicate JS error messages on UI (#28873) (#28881) + * Bump `@github/relative-time-element` to 4.3.1 (#28819) (#28826) +* MISC + * Warn that `DISABLE_QUERY_AUTH_TOKEN` is false only if it's explicitly defined (#28783) (#28868) + * Remove duplicated checkinit on git module (#28824) (#28831) + +## [1.21.4](https://github.com/go-gitea/gitea/releases/tag/v1.21.4) - 2024-01-16 + +* SECURITY + * Update github.com/cloudflare/circl (#28789) (#28790) + * Require token for GET subscription endpoint (#28765) (#28768) +* BUGFIXES + * Use refname:strip-2 instead of refname:short when syncing tags (#28797) (#28811) + * Fix links in issue card (#28806) (#28807) + * Fix nil pointer panic when exec some gitea cli command (#28791) (#28795) + * Require token for GET subscription endpoint (#28765) (#28778) + * Fix button size in "attached header right" (#28770) (#28774) + * Fix `convert.ToTeams` on empty input (#28426) (#28767) + * Hide code related setting options in repository when code unit is disabled (#28631) (#28749) + * Fix incorrect URL for "Reference in New Issue" (#28716) (#28723) + * Fix panic when parsing empty pgsql host (#28708) (#28709) + * Upgrade xorm to new version which supported update join for all supported databases (#28590) (#28668) + * Fix alpine package files are not rebuilt (#28638) (#28665) + * Avoid cycle-redirecting user/login page (#28636) (#28658) + * Fix empty ref for cron workflow runs (#28640) (#28647) + * Remove unnecessary syncbranchToDB with tests (#28624) (#28629) + * Use known issue IID to generate new PR index number when migrating from GitLab (#28616) (#28618) + * Fix flex container width (#28603) (#28605) + * Fix the scroll behavior for emoji/mention list (#28597) (#28601) + * Fix wrong due date rendering in issue list page (#28588) (#28591) + * Fix `status_check_contexts` matching bug (#28582) (#28589) + * Fix 500 error of searching commits (#28576) (#28579) + * Use information from previous blame parts (#28572) (#28577) + * Update mermaid for 1.21 (#28571) + * Fix 405 method not allowed CORS / OIDC (#28583) (#28586) (#28587) (#28611) + * Fix `GetCommitStatuses` (#28787) (#28804) + * Forbid removing the last admin user (#28337) (#28793) + * Fix schedule tasks bugs (#28691) (#28780) + * Fix issue dependencies (#27736) (#28776) + * Fix system webhooks API bug (#28531) (#28666) + * Fix when private user following user, private user will not be counted in his own view (#28037) (#28792) + * Render code block in activity tab (#28816) (#28818) +* ENHANCEMENTS + * Rework markup link rendering (#26745) (#28803) + * Modernize merge button (#28140) (#28786) + * Speed up loading the dashboard on mysql/mariadb (#28546) (#28784) + * Assign pull request to project during creation (#28227) (#28775) + * Show description as tooltip instead of title for labels (#28754) (#28766) + * Make template `DateTime` show proper tooltip (#28677) (#28683) + * Switch destination directory for apt signing keys (#28639) (#28642) + * Include heap pprof in diagnosis report to help debugging memory leaks (#28596) (#28599) +* DOCS + * Suggest to use Type=simple for systemd service (#28717) (#28722) + * Extend description for ARTIFACT_RETENTION_DAYS (#28626) (#28630) +* MISC + * Add -F to commit search to treat keywords as strings (#28744) (#28748) + * Add download attribute to release attachments (#28739) (#28740) + * Concatenate error in `checkIfPRContentChanged` (#28731) (#28737) + * Improve 1.21 document for Database Preparation (#28643) (#28644) + +## [1.21.3](https://github.com/go-gitea/gitea/releases/tag/v1.21.3) - 2023-12-21 + +* SECURITY + * Update golang.org/x/crypto (#28519) +* API + * chore(api): support ignore password if login source type is LDAP for creating user API (#28491) (#28525) + * Add endpoint for not implemented Docker auth (#28457) (#28462) +* ENHANCEMENTS + * Add option to disable ambiguous unicode characters detection (#28454) (#28499) + * Refactor SSH clone URL generation code (#28421) (#28480) + * Polyfill SubmitEvent for PaleMoon (#28441) (#28478) +* BUGFIXES + * Fix the issue ref rendering for wiki (#28556) (#28559) + * Fix duplicate ID when deleting repo (#28520) (#28528) + * Only check online runner when detecting matching runners in workflows (#28286) (#28512) + * Initalize stroage for orphaned repository doctor (#28487) (#28490) + * Fix possible nil pointer access (#28428) (#28440) + * Don't show unnecessary citation JS error on UI (#28433) (#28437) +* DOCS + * Update actions document about comparsion as Github Actions (#28560) (#28564) + * Fix documents for "custom/public/assets/" (#28465) (#28467) +* MISC + * Fix inperformant query on retrifing review from database. (#28552) (#28562) + * Improve the prompt for "ssh-keygen sign" (#28509) (#28510) + * Update docs for DISABLE_QUERY_AUTH_TOKEN (#28485) (#28488) + * Fix Chinese translation of config cheat sheet[API] (#28472) (#28473) + * Retry SSH key verification with additional CRLF if it failed (#28392) (#28464) + +## [1.21.2](https://github.com/go-gitea/gitea/releases/tag/v1.21.2) - 2023-12-12 + +* SECURITY + * Rebuild with recently released golang version + * Fix missing check (#28406) (#28411) + * Do some missing checks (#28423) (#28432) +* BUGFIXES + * Fix margin in server signed signature verification view (#28379) (#28381) + * Fix object does not exist error when checking citation file (#28314) (#28369) + * Use `filepath` instead of `path` to create SQLite3 database file (#28374) (#28378) + * Fix the runs will not be displayed bug when the main branch have no workflows but other branches have (#28359) (#28365) + * Handle repository.size column being NULL in migration v263 (#28336) (#28363) + * Convert git commit summary to valid UTF8. (#28356) (#28358) + * Fix migration panic due to an empty review comment diff (#28334) (#28362) + * Add `HEAD` support for rpm repo files (#28309) (#28360) + * Fix RPM/Debian signature key creation (#28352) (#28353) + * Keep profile tab when clicking on Language (#28320) (#28331) + * Fix missing issue search index update when changing status (#28325) (#28330) + * Fix wrong link in `protect_branch_name_pattern_desc` (#28313) (#28315) + * Read `previous` info from git blame (#28306) (#28310) + * Ignore "non-existing" errors when getDirectorySize calculates the size (#28276) (#28285) + * Use appSubUrl for OAuth2 callback URL tip (#28266) (#28275) + * Meilisearch: require all query terms to be matched (#28293) (#28296) + * Fix required error for token name (#28267) (#28284) + * Fix issue will be detected as pull request when checking `First-time contributor` (#28237) (#28271) + * Use full width for project boards (#28225) (#28245) + * Increase "version" when update the setting value to a same value as before (#28243) (#28244) + * Also sync DB branches on push if necessary (#28361) (#28403) + * Make gogit Repository.GetBranchNames consistent (#28348) (#28386) + * Recover from panic in cron task (#28409) (#28425) + * Deprecate query string auth tokens (#28390) (#28430) +* ENHANCEMENTS + * Improve doctor cli behavior (#28422) (#28424) + * Fix margin in server signed signature verification view (#28379) (#28381) + * Refactor template empty checks (#28351) (#28354) + * Read `previous` info from git blame (#28306) (#28310) + * Use full width for project boards (#28225) (#28245) + * Enable system users search via the API (#28013) (#28018) + +## [1.21.1](https://github.com/go-gitea/gitea/releases/tag/v1.21.1) - 2023-11-26 + +* SECURITY + * Fix comment permissions (#28213) (#28216) +* BUGFIXES + * Fix delete-orphaned-repos (#28200) (#28202) + * Make CORS work for oauth2 handlers (#28184) (#28185) + * Fix missing buttons (#28179) (#28181) + * Fix no ActionTaskOutput table waring (#28149) (#28152) + * Fix empty action run title (#28113) (#28148) + * Use "is-loading" to avoid duplicate form submit for code comment (#28143) (#28147) + * Fix Matrix and MSTeams nil dereference (#28089) (#28105) + * Fix incorrect pgsql conn builder behavior (#28085) (#28098) + * Fix system config cache expiration timing (#28072) (#28090) + * Restricted users only see repos in orgs which their team was assigned to (#28025) (#28051) +* API + * Fix permissions for Token DELETE endpoint to match GET and POST (#27610) (#28099) +* ENHANCEMENTS + * Do not display search box when there's no packages yet (#28146) (#28159) + * Add missing `packages.cleanup.success` (#28129) (#28132) +* DOCS + * Docs: Replace deprecated IS_TLS_ENABLED mailer setting in email setup (#28205) (#28208) + * Fix the description about the default setting for action in quick start document (#28160) (#28168) + * Add guide page to actions when there's no workflows (#28145) (#28153) +* MISC + * Use full width for PR comparison (#28182) (#28186) + +## [1.21.0](https://github.com/go-gitea/gitea/releases/tag/v1.21.0) - 2023-11-14 + +* BREAKING + * Restrict certificate type for builtin SSH server (#26789) + * Refactor to use urfave/cli/v2 (#25959) + * Move public asset files to the proper directory (#25907) + * Remove commit status running and warning to align GitHub (#25839) (partially reverted: Restore warning commit status (#27504) (#27529)) + * Remove "CHARSET" config option for MySQL, always use "utf8mb4" (#25413) + * Set SSH_AUTHORIZED_KEYS_BACKUP to false (#25412) +* FEATURES + * User details page (#26713) + * Chore(actions): support cron schedule task (#26655) + * Support rebuilding issue indexer manually (#26546) + * Allow to archive labels (#26478) + * Add disable workflow feature (#26413) + * Support `.git-blame-ignore-revs` file (#26395) + * Pre-register OAuth2 applications for git credential helpers (#26291) + * Add `Retry` button when creating a mirror-repo fails (#26228) + * Artifacts retention and auto clean up (#26131) + * Serve pre-defined files in "public", add "security.txt", add CORS header for ".well-known" (#25974) + * Implement auto-cancellation of concurrent jobs if the event is push (#25716) + * Newly pushed branches hints on repository home page (#25715) + * Display branch commit status (#25608) + * Add direct serving of package content (#25543) + * Add commits dropdown in PR files view and allow commit by commit review (#25528) + * Allow package cleanup from admin page (#25307) + * Batch delete issue and improve tippy opts (#25253) + * Show branches and tags that contain a commit (#25180) + * Add actor and status dropdowns to run list (#25118) + * Allow Organisations to have a E-Mail (#25082) + * Add codeowners feature (#24910) + * Actions Artifacts support uploading multiple files and directories (#24874) + * Support configuration variables on Gitea Actions (#24724) + * Support downloading raw task logs (#24451) +* API + * Unify two factor check (#27915) (#27929) + * Fix package webhook (#27839) (#27855) + * Fix/upload artifact error windows (#27802) (#27840) + * Fix bad method call when deleting user secrets via API (#27829) (#27831) + * Do not force creation of _cargo-index repo on publish (#27266) (#27765) + * Delete repos of org when purge delete user (#27273) (#27728) + * Fix org team endpoint (#27721) (#27727) + * Api: GetPullRequestCommits: return file list (#27483) (#27539) + * Don't let API add 2 exclusive labels from same scope (#27433) (#27460) + * Redefine the meaning of column is_active to make Actions Registration Token generation easier (#27143) (#27304) + * Fix PushEvent NullPointerException jenkinsci/github-plugin (#27203) (#27251) + * Fix organization field being null in POST /orgs/{orgid}/teams (#27150) (#27163) + * Allow empty Conan files (#27092) + * Fix token endpoints ignore specified account (#27080) + * Reduce usage of `db.DefaultContext` (#27073) (#27083) (#27089) (#27103) (#27262) (#27265) (#27347) (#26076) + * Make SSPI auth mockable (#27036) + * Extract auth middleware from service (#27028) + * Add `RemoteAddress` to mirrors (#26952) + * Feat(API): add routes and functions for managing user's secrets (#26909) + * Feat(API): add secret deletion functionality for repository (#26808) + * Feat(API): add route and implementation for creating/updating repository secret (#26766) + * Add Upload URL to release API (#26663) + * Feat(API): update and delete secret for managing organization secrets (#26660) + * Feat: implement organization secret creation API (#26566) + * Add API route to list org secrets (#26485) + * Set commit id when ref used explicitly (#26447) + * PATCH branch-protection updates check list even when checks are disabled (#26351) + * Add file status for API "Get a single commit from a repository" (#16205) (#25831) + * Add API for changing Avatars (#25369) +* BUGFIXES + * Fix viewing wiki commit on empty repo (#28040) (#28044) + * Enable system users for comment.LoadPoster (#28014) (#28032) + * Fixed duplicate attachments on dump on windows (#28019) (#28031) + * Fix wrong xorm Delete usage(backport for 1.21) (#28002) + * Add word-break to repo description in home page (#27924) (#27957) + * Fix rendering assignee changed comments without assignee (#27927) (#27952) + * Add word break to release title (#27942) (#27947) + * Fix JS NPE when viewing specific range of PR commits (#27912) (#27923) + * Show correct commit sha when viewing single commit diff (#27916) (#27921) + * Fix 500 when deleting a dismissed review (#27903) (#27910) + * Fix DownloadFunc when migrating releases (#27887) (#27890) + * Fix http protocol auth (#27875) (#27876) + * Refactor postgres connection string building (#27723) (#27869) + * Close all hashed buffers (#27787) (#27790) + * Fix label render containing invalid HTML (#27752) (#27762) + * Fix duplicate project board when hitting `enter` key (#27746) (#27751) + * Fix `link-action` redirect network error (#27734) (#27749) + * Fix sticky diff header background (#27697) (#27712) + * Always delete existing scheduled action tasks (#27662) (#27688) + * Support allowed hosts for webhook to work with proxy (#27655) (#27675) + * Fix poster is not loaded in get default merge message (#27657) (#27666) + * Improve dropdown button alignment and fix hover bug (#27632) (#27637) + * Improve retrying index issues (#27554) (#27634) + * Fix 404 when deleting Docker package with an internal version (#27615) (#27630) + * Backport manually for a tmpl issue in v1.21 (#27612) + * Don't show Link to TOTP if not set up (#27585) (#27588) + * Fix data-race bug when accessing task.LastRun (#27584) (#27586) + * Fix attachment download bug (#27486) (#27571) + * Respect SSH.KeygenPath option when calculating ssh key fingerprints (#27536) (#27551) + * Improve dropdown's behavior when there is a search input in menu (#27526) (#27534) + * Fix panic in storageHandler (#27446) (#27479) + * When comparing with an non-exist repository, return 404 but 500 (#27437) (#27442) + * Fix pr template (#27436) (#27440) + * Fix git 2.11 error when checking IsEmpty (#27393) (#27397) + * Allow get release download files and lfs files with oauth2 token format (#26430) (#27379) + * Fix missing ctx for GetRepoLink in dashboard (#27372) (#27375) + * Absolute positioned checkboxes overlay floated elements (#26870) (#27366) + * Introduce fixes and more rigorous tests for 'Show on a map' feature (#26803) (#27365) + * Fix repo count in org action settings (#27245) (#27353) + * Add logs for data broken of comment review (#27326) (#27345) + * Fix the approval count of PR when there is no protection branch rule (#27272) (#27343) + * Fix Bug in Issue Config when only contact links are set (#26521) (#27334) + * Improve issue history dialog and make poster can delete their own history (#27323) (#27327) + * Fix orphan check for deleted branch (#27310) (#27321) + * Fix protected branch icon location (#26576) (#27317) + * Fix yaml test (#27297) (#27303) + * Fix some animation bugs (#27287) (#27294) + * Fix incorrect change from #27231 (#27275) (#27282) + * Add missing public user visibility in user details page (#27246) (#27250) + * Fix EOL handling in web editor (#27141) (#27234) + * Fix issues on action runners page (#27226) (#27233) + * Quote table `release` in sql queries (#27205) (#27218) + * Fix release URL in webhooks (#27182) (#27185) + * Fix review request number and add more tests (#27104) (#27168) + * Fix the variable regexp pattern on web page (#27161) (#27164) + * Fix: treat tab "overview" as "repositories" in user profiles without readme (#27124) + * Fix NPE when editing OAuth2 applications (#27078) + * Fix the incorrect route path in the user edit page. (#27007) + * Fix the secret regexp pattern on web page (#26910) + * Allow users with write permissions for issues to add attachments with API (#26837) + * Make "link-action" backend code respond correct JSON content (#26680) + * Use line-height: normal by default (#26635) + * Fix NPM packages name validation (#26595) + * Rewrite the DiffFileTreeItem and fix misalignment (#26565) + * Return empty when searching issues with no repos (#26545) + * Explain SearchOptions and fix ToSearchOptions (#26542) + * Add missing triggers to update issue indexer (#26539) + * Handle base64 decoding correctly to avoid panic (#26483) + * Avoiding accessing undefined mentionValues (#26461) + * Fix incorrect redirection in new issue using references (#26440) + * Fix the bug when getting files changed for `pull_request_target` event (#26320) + * Remove IsWarning in tmpl (#26120) + * Fix loading `LFS_JWT_SECRET` from wrong section (#26109) + * Fixing redirection issue for logged-in users (#26105) + * Improve "gitea doctor" sub-command and fix "help" commands (#26072) + * Fix the truncate and alignment problem for some admin tables (#26042) + * Update minimum password length requirements (#25946) + * Do not "guess" the file encoding/BOM when using API to upload files (#25828) + * Restructure issue list template, styles (#25750) + * Fix `ref` for workflows triggered by `pull_request_target` (#25743) + * Fix issues indexer document mapping (#25619) + * Use JSON response for "user/logout" (#25522) + * Fix migrate page layout on mobile (#25507) + * Link to existing PR when trying to open a new PR on the same branches (#25494) + * Do not publish docker release images on `-dev` tags (#25471) + * Support `pull_request_target` event (#25229) + * Modify the content format of the Feishu webhook (#25106) +* ENHANCEMENTS + * Render email addresses as such if followed by punctuation (#27987) (#27992) + * Show error toast when file size exceeds the limits (#27985) (#27986) + * Fix citation error when the file size is larger than 1024 bytes (#27958) (#27965) + * Remove action runners on user deletion (#27902) (#27908) + * Remove set tabindex on view issue (#27892) (#27896) + * Reduce margin/padding on flex-list items and divider (#27872) (#27874) + * Change katex limits (#27823) (#27868) + * Clean up template locale usage (#27856) (#27857) + * Add dedicated class for empty placeholders (#27788) (#27792) + * Add gap between diff boxes (#27776) (#27781) + * Fix incorrect "tab" parameter for repo search sub-template (#27755) (#27764) + * Enable followCursor for language stats bar (#27713) (#27739) + * Improve diff tree spacing (#27714) (#27719) + * Feed UI Improvements (#27356) (#27717) + * Improve feed icons and feed merge text color (#27498) (#27716) + * [FIX] resolve confusing colors in languages stats by insert a gap (#27704) (#27715) + * Add doctor dbconsistency fix to delete repos with no owner (#27290) (#27693) + * Fix required checkboxes in issue forms (#27592) (#27692) + * Hide archived labels by default from the suggestions when assigning labels for an issue (#27451) (#27661) + * Cleanup repo details icons/labels (#27644) (#27654) + * Keep filter when showing unfiltered results on explore page (#27192) (#27589) + * Show manual cron run's last time (#27544) (#27577) + * Revert "Fix pr template (#27436)" (#27567) + * Increase queue length (#27555) (#27562) + * Avoid run change title process when the title is same (#27467) (#27558) + * Remove max-width and add hide text overflow (#27359) (#27550) + * Add hover background to wiki list page (#27507) (#27521) + * Fix mermaid flowchart margin issue (#27503) (#27516) + * Refactor system setting (#27000) (#27452) + * Fix missing `ctx` in new_form.tmpl (#27434) (#27438) + * Add Index to `action.user_id` (#27403) (#27425) + * Don't use subselect in `DeleteIssuesByRepoID` (#27332) (#27408) + * Add support for HEAD ref in /src/branch and /src/commit routes (#27384) (#27407) + * Make Actions tasks/jobs timeouts configurable by the user (#27400) (#27402) + * Hide archived labels when filtering by labels on the issue list (#27115) (#27381) + * Highlight user details link (#26998) (#27376) + * Add protected branch name description (#27257) (#27351) + * Improve tree not found page (#26570) (#27346) + * Add Index to `comment.dependent_issue_id` (#27325) (#27340) + * Improve branch list UI (#27319) (#27324) + * Fix divider in subscription page (#27298) (#27301) + * Add missed return to actions view fetch (#27289) (#27293) + * Backport ctx locale refactoring manually (#27231) (#27259) (#27260) + * Disable `Test Delivery` and `Replay` webhook buttons when webhook is inactive (#27211) (#27253) + * Use mask-based fade-out effect for `.new-menu` (#27181) (#27243) + * Cleanup locale function usage (#27227) (#27240) + * Fix z-index on markdown completion (#27237) (#27239) + * Fix Fomantic UI dropdown icon bug when there is a search input in menu (#27225) (#27228) + * Allow copying issue comment link on archived repos and when not logged in (#27193) (#27210) + * Fix: text decorator on issue sidebar menu label (#27206) (#27209) + * Fix dropdown icon position (#27175) (#27177) + * Add index to `issue_user.issue_id` (#27154) (#27158) + * Increase auth provider icon size on login page (#27122) + * Remove a `gt-float-right` and some unnecessary helpers (#27110) + * Change green buttons to primary color (#27099) + * Use db.WithTx for AddTeamMember to avoid ctx abuse (#27095) + * Use `print` instead of `printf` (#27093) + * Remove the useless function `GetUserIssueStats` and move relevant tests to `indexer_test.go` (#27067) + * Search branches (#27055) + * Display all user types and org types on admin management UI (#27050) + * Ui correction in mobile view nav bar left aligned items. (#27046) + * Chroma color tweaks (#26978) + * Move some functions to service layer (#26969) + * Improve "language stats" UI (#26968) + * Replace `util.SliceXxx` with `slices.Xxx` (#26958) + * Refactor dashboard/feed.tmpl (#26956) + * Move repository deletion to service layer (#26948) + * Fix the missing repo count (#26942) + * Improve hint when uploading a too large avatar (#26935) + * Extract common code to new template (#26933) + * Move createrepository from module to service layer (#26927) + * Move notification interface to services layer (#26915) + * Move feed notification service layer (#26908) + * Move ui notification to service layer (#26907) + * Move indexer notification to service layer (#26906) + * Move mail notification logic to service layer (#26905) + * Extract common code to new template (#26903) + * Show queue's active worker number (#26896) + * Fix media description render for orgmode (#26895) + * Remove CSS `has` selector and improve various styles (#26891) + * Relocate the `RSS user feed` button (#26882) + * Refactor "shortsha" (#26877) + * Refactor `og:description` to limit the max length (#26876) + * Move web/api context related testing function into a separate package (#26859) + * Redable error on S3 storage connection failure (#26856) + * Improve opengraph previews (#26851) + * Add more descriptive error on forgot password page (#26848) + * Show always repo count in header (#26842) + * Remove "TODO" tasks from CSS file (#26835) + * Render code blocks in repo description (#26830) + * Minor dashboard tweaks, fix flex-list margins (#26829) + * Remove polluted `.ui.right` (#26825) + * Display archived labels specially when listing labels (#26820) + * Remove polluted ".ui.left" style (#26809) + * Make it posible to customize nav text color via css var (#26807) + * Refactor lfs requests (#26783) + * Improve flex list item padding (#26779) + * Remove fomantic `text` module (#26777) + * Remove fomantic `item` module (#26775) + * Remove redundant nil check in `WalkGitLog` (#26773) + * Reduce some allocations in type conversion (#26772) + * Refactor some CSS styles and simplify code (#26771) + * Unify `border-radius` behavior (#26770) + * Improve modal dialog UI (#26764) + * Allow "latest" to be used in release vTag when downloading file (#26748) + * Adding hint `Archived` to archive label. (#26741) + * Move `modules/mirror` to `services` (#26737) + * Add "dir=auto" for input/textarea elements by default (#26735) + * Add auth-required to config.json for Cargo http registry (#26729) + * Simplify helper CSS classes and avoid abuse (#26728) + * Make web context initialize correctly for different cases (#26726) + * Focus editor on "Write" tab click (#26714) + * Remove incorrect CSS helper classes (#26712) + * Fix review bar misalignment (#26711) + * Add reverseproxy auth for API back with default disabled (#26703) + * Add default label in branch select list (#26697) + * Improve Image Diff UI (#26696) + * Fixed text overflow in dropdown menu (#26694) + * [Refactor] getIssueStatsChunk to move inner function into own one (#26671) + * Remove fomantic loader module (#26670) + * Add `member`, `collaborator`, `contributor`, and `first-time contributor` roles and tooltips (#26658) + * Improve some flex layouts (#26649) + * Improve the branch selector tab UI (#26631) + * Improve show role (#26621) + * Remove avatarHTML from template helpers (#26598) + * Allow text selection in actions step header (#26588) + * Improve translation of milestone filters (#26569) + * Add optimistic lock to ActionRun table (#26563) + * Update team invitation email link (#26550) + * Differentiate better between user settings and admin settings (#26538) + * Check disabled workflow when rerun jobs (#26535) + * Improve deadline icon location in milestone list page (#26532) + * Improve repo sub menu (#26531) + * Fix the display of org level badges (#26504) + * Rename `Sync2` -> `Sync` (#26479) + * Fix stderr usages (#26477) + * Remove fomantic transition module (#26469) + * Refactor tests (#26464) + * Refactor project templates (#26448) + * Fall back to esbuild for css minify (#26445) + * Always show usernames in reaction tooltips (#26444) + * Use correct pull request commit link instead of a generic commit link (#26434) + * Refactor "editorconfig" (#26391) + * Make `user-content-* ` consistent with github (#26388) + * Remove unnecessary template helper repoAvatar (#26387) + * Remove unnecessary template helper DisableGravatar (#26386) + * Use template context function for avatar rendering (#26385) + * Rename code_langauge.go to code_language.go (#26377) + * Use more `IssueList` instead of `[]*Issue` (#26369) + * Do not highlight `#number` in documents (#26365) + * Fix display problems of members and teams unit (#26363) + * Fix 404 error when remove self from an organization (#26362) + * Improve CLI and messages (#26341) + * Refactor backend SVG package and add tests (#26335) + * Add link to job details and tooltip to commit status in repo list in dashboard (#26326) + * Use yellow if an approved review is stale (#26312) + * Remove commit load branches and tags in wiki repo (#26304) + * Add highlight to selected repos in milestone dashboard (#26300) + * Delete `issue_service.CreateComment` (#26298) + * Do not show Profile README when repository is private (#26295) + * Tweak actions menu (#26278) + * Start using template context function (#26254) + * Use calendar icon for `Joined on...` in profiles (#26215) + * Add 'Show on a map' button to Location in profile, fix layout (#26214) + * Render plaintext task list items for markdown files (#26186) + * Add tooltip to describe LFS table column and color `delete LFS file` button red (#26181) + * Release attachments duplicated check (#26176) + * De-emphasize issue sidebar buttons (#26171) + * Fixing the align of commit stats in commit_page template. (#26161) + * Allow editing push mirrors after creation (#26151) + * Move web JSON functions to web context and simplify code (#26132) + * Refactor improve NoBetterThan (#26126) + * Improve clickable area in repo action view page (#26115) + * Add context parameter to some database functions (#26055) + * Docusaurus-ify (#26051) + * Improve text for empty issue/pr description (#26047) + * Categorize admin settings sidebar panel (#26030) + * Remove redundant "RouteMethods" method (#26024) + * Refactor and enhance issue indexer to support both searching, filtering and paging (#26012) + * Add a link to OpenID Issuer URL in WebFinger response (#26000) + * Fix UI for release tag page / wiki page / subscription page (#25948) + * Support copy protected branch from template repository (#25889) + * Improve display of Labels/Projects/Assignees sort options (#25886) + * Fix margin on the new/edit project page. (#25885) + * Show image size on view page (#25884) + * Remove ref name in PR commits page (#25876) + * Allow the use of alternative net.Listener implementations by downstreams (#25855) + * Refactor "Content" for file uploading (#25851) + * Add error info if no user can fork the repo (#25820) + * Show edit title button on commits tab of PR, too (#25791) + * Introduce `flex-list` & `flex-item` elements for Gitea UI (#25790) + * Don't stack PR tab menu on small screens (#25789) + * Repository Archived text title center align (#25767) + * Make route middleware/handler mockable (#25766) + * Move issue filters to shared template (#25729) + * Use frontend fetch for branch dropdown component (#25719) + * Add open/closed field support for issue index (#25708) + * Some less naked returns (#25682) + * Fix inconsistent user profile layout across tabs (#25625) + * Get latest commit statuses from database instead of git data on dashboard for repositories (#25605) + * Adding branch-name copy to clipboard branches screen. (#25596) + * Update emoji set to Unicode 15 (#25595) + * Move some files under repo/setting (#25585) + * Add custom ansi colors and CSS variables for them (#25546) + * Add log line anchor for action logs (#25532) + * Use flex instead of float for sort button and search input (#25519) + * Update octicons and use `octicon-file-directory-symlink` (#25453) + * Add toasts to UI (#25449) + * Fine tune project board label colors and modal content background (#25419) + * Import additional secrets via file uri (#25408) + * Switch to ansi_up for ansi rendering in actions (#25401) + * Store and use seconds for timeline time comments (#25392) + * Support displaying diff stats in PR tab bar (#25387) + * Use fetch form action for lock/unlock/pin/unpin on sidebar (#25380) + * Refactor: TotalTimes return seconds (#25370) + * Navbar styling rework (#25343) + * Introduce shared template for search inputs (#25338) + * Only show 'Manage Account Links' when necessary (#25311) + * Improve 'Privacy' section in profile settings (#25309) + * Substitute variables in path names of template repos too (#25294) + * Fix tags line no margin see #25255 (#25280) + * Use fetch to send requests to create issues/comments (#25258) + * Change form actions to fetch for submit review box (#25219) + * Improve AJAX link and modal confirm dialog (#25210) + * Reduce unnecessary DB queries for Actions tasks (#25199) + * Disable `Create column` button while the column name is empty (#25192) + * Refactor indexer (#25174) + * Adjust style for action run list (align icons, adjust padding) (#25170) + * Remove duplicated functions when deleting a branch (#25128) + * Make confusable character warning less jarring (#25069) + * Highlight viewed files differently in the PR filetree (#24956) + * Support changing labels of Actions runner without re-registration (#24806) + * Fix duplicate Reviewed-by trailers (#24796) + * Resolve issue with sort icons on admin/users and admin/runners (#24360) + * Split lfs size from repository size (#22900) + * Sync branches into databases (#22743) + * Disable run user change in installation page (#22499) + * Add merge files files to GetCommitFileStatus (#20515) + * Show OpenID Connect and OAuth on signup page (#20242) +* SECURITY + * Dont leak private users via extensions (#28023) (#28029) + * Expanded minimum RSA Keylength to 3072 (#26604) +* TESTING + * Add user secrets API integration tests (#27832) (#27852) + * Add tests for db indexer in indexer_test.go (#27087) + * Speed up TestEventSourceManagerRun (#26262) + * Add unit test for user renaming (#26261) + * Add some Wiki unit tests (#26260) + * Improve unit test for caching (#26185) + * Add unit test for `HashAvatar` (#25662) +* TRANSLATION + * Backport translations to v1.21 (#27899) + * Fix issues in translation file (#27699) (#27737) + * Add locale for deleted head branch (#26296) + * Improve multiple strings in en-US locale (#26213) + * Fix broken translations for package documantion (#25742) + * Correct translation wrong format (#25643) +* BUILD + * Dockerfile small refactor (#27757) (#27826) + * Fix build errors on BSD (in BSDMakefile) (#27594) (#27608) + * Fully replace drone with actions (#27556) (#27575) + * Enable markdownlint `no-duplicate-header` (#27500) (#27506) + * Enable production source maps for index.js, fix CSS sourcemaps (#27291) (#27295) + * Update snap package (#27021) + * Bump go to 1.21 (#26608) + * Bump xgo to go-1.21.x and node to 20 in release-version (#26589) + * Add template linting via djlint (#25212) +* DOCS + * Change default size of issue/pr attachments and repo file (#27946) (#28017) + * Remove `known issue` section in Gitea Actions Doc (#27930) (#27938) + * Remove outdated paragraphs when comparing Gitea Actions to GitHub Actions (#27119) + * Update brew installation documentation since gitea moved to brew core package (#27070) + * Actions are no longer experimental, so enable them by default (#27054) + * Add a documentation note for Windows Service (#26938) + * Add sparse url in cargo package guide (#26937) + * Update nginx recommendations (#26924) + * Update backup instructions to align with archive structure (#26902) + * Expanding documentation in queue.go (#26889) + * Update info regarding internet connection for build (#26776) + * Docs: template variables (#26547) + * Update index doc (#26455) + * Update zh-cn documentation (#26406) + * Fix typos and grammer problems for actions documentation (#26328) + * Update documentation for 1.21 actions (#26317) + * Doc update swagger doc for POST /orgs/{org}/teams (#26155) + * Doc sync authentication.md to zh-cn (#26117) + * Doc guide the user to create the appropriate level runner (#26091) + * Make organization redirect warning more clear (#26077) + * Update blog links (#25843) + * Fix default value for LocalURL (#25426) + * Update `from-source.zh-cn.md` & `from-source.en-us.md` - Cross Compile Using Zig (#25194) +* MISC + * Replace deprecated `elliptic.Marshal` (#26800) + * Add elapsed time on debug for slow git commands (#25642) + +## [1.20.5](https://github.com/go-gitea/gitea/releases/tag/v1.20.5) - 2023-10-03 + +* ENHANCEMENTS + * Fix z-index on markdown completion (#27237) (#27242 & #27238) + * Use secure cookie for HTTPS sites (#26999) (#27013) +* BUGFIXES + * Fix git 2.11 error when checking IsEmpty (#27393) (#27396) + * Allow get release download files and lfs files with oauth2 token format (#26430) (#27378) + * Fix orphan check for deleted branch (#27310) (#27320) + * Quote table `release` in sql queries (#27205) (#27219) + * Fix release URL in webhooks (#27182) (#27184) + * Fix successful return value for `SyncAndGetUserSpecificDiff` (#27152) (#27156) + * fix pagination for followers and following (#27127) (#27138) + * Fix issue templates when blank isses are disabled (#27061) (#27082) + * Fix context cache bug & enable context cache for dashabord commits' authors(#26991) (#27017) + * Fix INI parsing for value with trailing slash (#26995) (#27001) + * Fix PushEvent NullPointerException jenkinsci/github-plugin (#27203) (#27249) + * Fix organization field being null in POST /orgs/{orgid}/teams (#27150) (#27167 & #27162) + * Fix bug of review request number (#27406) (#27104) +* TESTING + * services/wiki: Close() after error handling (#27129) (#27137) +* DOCS + * Improve actions docs related to `pull_request` event (#27126) (#27145) +* MISC + * Add logs for data broken of comment review (#27326) (#27344) + * Load reviewer before sending notification (#27063) (#27064) + ## [1.20.4](https://github.com/go-gitea/gitea/releases/tag/v1.20.4) - 2023-09-08 * SECURITY @@ -428,7 +1137,6 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Add option to search for users is active join a team (#24093) * Add PDF rendering via PDFObject (#24086) * Refactor web route (#24080) - * Make more functions use ctx instead of db.DefaultContext (#24068) * Make HTML template functions support context (#24056) * Refactor rename user and rename organization (#24052) * Localize milestone related time strings (#24051) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd83a48986..5d20bc25894 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ - [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) - [Dependencies](#dependencies) - [Backend](#backend) @@ -47,6 +48,7 @@ - [Release Cycle](#release-cycle) - [Maintainers](#maintainers) - [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) @@ -102,6 +104,13 @@ 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 the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea). @@ -110,7 +119,7 @@ See the [development setup instructions](https://docs.gitea.com/development/hack ### Backend -Go dependencies are managed using [Go Modules](https://golang.org/cmd/go/#hdr-Module_maintenance). \ +Go dependencies are managed using [Go Modules](https://go.dev/cmd/go/#hdr-Module_maintenance). \ You can find more details in the [go mod documentation](https://go.dev/ref/mod) and the [Go Modules Wiki](https://github.com/golang/go/wiki/Modules). Pull requests should only modify `go.mod` and `go.sum` where it is related to your change, be it a bugfix or a new feature. \ @@ -167,7 +176,7 @@ Here's how to run the test suite: | Command | Action | | | :------------------------------------- | :----------------------------------------------- | ------------ | -|``make test[\#SpecificTestName]`` | run unit test(s) | +|``make test[\#SpecificTestName]`` | run unit test(s) | | |``make test-sqlite[\#SpecificTestName]``| run [integration](tests/integration) test(s) for SQLite |[More details](tests/integration/README.md) | |``make test-e2e-sqlite[\#SpecificTestName]``| run [end-to-end](tests/e2e) test(s) for SQLite |[More details](tests/e2e/README.md) | @@ -203,10 +212,20 @@ Some of the key points: 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. \ +In the PR summary, you can describe exactly how you are fixing this problem. + 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 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. + 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 @@ -225,17 +244,20 @@ PRs without a milestone may not be merged. ### Labels -Every PR should be labeled correctly with every label that applies. \ -This includes especially the distinction between `bug` (fixing existing functionality), `feature` (new functionality), `enhancement` (upgrades for existing functionality), and `refactoring` (improving the internal code structure without changing the output (much)). \ -Furthermore, +Almost all labels used inside Gitea can be classified as one of the following: -- the amount of pending required approvals -- whether this PR is `blocked`, a `backport` or `breaking` -- if it targets the `ui` or `api` -- if it increases the application `speed` -- reduces `memory usage` +- `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/…` / `pr/…`: 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 `pr/need-2-approvals` + +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 -are oftentimes notable labels. +- the amount of pending required approvals +- has all `backport`s or needs a manual backport ### Breaking PRs @@ -252,13 +274,16 @@ Changing the default value of a setting or replacing the setting with another on #### How to handle breaking PRs? -If your PR has a breaking change, you must add a `BREAKING` section to your PR summary, e.g. +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: ``` -To explain how this will affect users and how to mitigate these changes. +Breaking PRs will not be merged as long as not both of these requirements are met. ### Maintaining open PRs @@ -439,7 +464,7 @@ We assume in good faith that the information you provide is legally binding. We adopted a release schedule to streamline the process of working on, finishing, and issuing releases. \ The overall goal is to make a major release every three or four months, which breaks down into two or three months of general development followed by one month of testing and polishing known as the release freeze. \ All the feature pull requests should be -merged before feature freeze. And, during the frozen period, a corresponding +merged before feature freeze. All feature pull requests haven't been merged before this feature freeze will be moved to next milestone, please notice our feature freeze announcement on discord. And, during the frozen period, a corresponding release branch is open for fixes backported from main branch. Release candidates are made during this period for user testing to obtain a final version that is maintained in this branch. @@ -470,36 +495,53 @@ if possible provide GPG signed commits. https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/ https://help.github.com/articles/signing-commits-with-gpg/ +Furthermore, any account with write access (like bots and TOC members) **must** use 2FA. +https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/ + ## 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 would be elected as it has been over the past years, and the other three would consist of appointed members from the Gitea company. +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/ -When the new community members have been elected, the old members will give up ownership to the newly elected members. For security reasons, TOC members or any account with write access (like a bot) must use 2FA. -https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/ +### 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. ### Current TOC members -- 2023-01-01 ~ 2023-12-31 - https://blog.gitea.com/quarterly-23q1/ +- 2024-01-01 ~ 2024-12-31 - Company - [Jason Song](https://gitea.com/wolfogre) - [Lunny Xiao](https://gitea.com/lunny) - - [Matti Ranta](https://gitea.com/techknowlogick) + - [Matti Ranta](https://gitea.com/techknowlogick) - Community - [6543](https://gitea.com/6543) <6543@obermui.de> - - [Andrew Thornton](https://gitea.com/zeripath) + - [delvh](https://gitea.com/delvh) - [John Olheiser](https://gitea.com/jolheiser) ### 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) +- [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-Haberkorns](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) -- [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) -- [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) +- [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 +- [John Olheiser](https://gitea.com/jolheiser) - 2023 +- [Jason Song](https://gitea.com/wolfogre) - 2023 ## Governance Compensation diff --git a/Dockerfile b/Dockerfile index b42b4daa5fa..b647c0cd590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -#Build stage -FROM docker.io/library/golang:1.21-alpine3.18 AS build-env +# Build stage +FROM docker.io/library/golang:1.22-alpine3.19 AS build-env ARG GOPROXY ENV GOPROXY ${GOPROXY:-direct} @@ -9,21 +9,39 @@ ARG TAGS="sqlite sqlite_unlock_notify" ENV TAGS "bindata timetzdata $TAGS" ARG CGO_EXTRA_CFLAGS -#Build deps -RUN apk --no-cache add build-base git nodejs npm +# Build deps +RUN apk --no-cache add \ + build-base \ + git \ + nodejs \ + npm \ + && rm -rf /var/cache/apk/* -#Setup repo +# Setup repo COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea -#Checkout version if set +# Checkout version if set RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ && make clean-all build # Begin env-to-ini build RUN go build contrib/environment-to-ini/environment-to-ini.go -FROM docker.io/library/alpine:3.18 +# Copy local files +COPY docker/root /tmp/local + +# Set permissions +RUN chmod 755 /tmp/local/usr/bin/entrypoint \ + /tmp/local/usr/local/bin/gitea \ + /tmp/local/etc/s6/gitea/* \ + /tmp/local/etc/s6/openssh/* \ + /tmp/local/etc/s6/.s6-svscan/* \ + /go/src/code.gitea.io/gitea/gitea \ + /go/src/code.gitea.io/gitea/environment-to-ini +RUN chmod 644 /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete + +FROM docker.io/library/alpine:3.19 LABEL maintainer="maintainers@gitea.io" EXPOSE 22 3000 @@ -39,7 +57,8 @@ RUN apk --no-cache add \ s6 \ sqlite \ su-exec \ - gnupg + gnupg \ + && rm -rf /var/cache/apk/* RUN addgroup \ -S -g 1000 \ @@ -61,10 +80,7 @@ VOLUME ["/data"] ENTRYPOINT ["/usr/bin/entrypoint"] CMD ["/bin/s6-svscan", "/etc/s6"] -COPY docker/root / +COPY --from=build-env /tmp/local / COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea COPY --from=build-env /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini COPY --from=build-env /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh -RUN chmod 755 /usr/bin/entrypoint /app/gitea/gitea /usr/local/bin/gitea /usr/local/bin/environment-to-ini -RUN chmod 755 /etc/s6/gitea/* /etc/s6/openssh/* /etc/s6/.s6-svscan/* -RUN chmod 644 /etc/profile.d/gitea_bash_autocomplete.sh diff --git a/Dockerfile.rootless b/Dockerfile.rootless index 449e630fadb..dd7da972783 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -1,5 +1,5 @@ -#Build stage -FROM docker.io/library/golang:1.21-alpine3.18 AS build-env +# Build stage +FROM docker.io/library/golang:1.22-alpine3.19 AS build-env ARG GOPROXY ENV GOPROXY ${GOPROXY:-direct} @@ -10,20 +10,36 @@ ENV TAGS "bindata timetzdata $TAGS" ARG CGO_EXTRA_CFLAGS #Build deps -RUN apk --no-cache add build-base git nodejs npm +RUN apk --no-cache add \ + build-base \ + git \ + nodejs \ + npm \ + && rm -rf /var/cache/apk/* -#Setup repo +# Setup repo COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea -#Checkout version if set +# Checkout version if set RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ && make clean-all build # Begin env-to-ini build RUN go build contrib/environment-to-ini/environment-to-ini.go -FROM docker.io/library/alpine:3.18 +# Copy local files +COPY docker/rootless /tmp/local + +# Set permissions +RUN chmod 755 /tmp/local/usr/local/bin/docker-entrypoint.sh \ + /tmp/local/usr/local/bin/docker-setup.sh \ + /tmp/local/usr/local/bin/gitea \ + /go/src/code.gitea.io/gitea/gitea \ + /go/src/code.gitea.io/gitea/environment-to-ini +RUN chmod 644 /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete + +FROM docker.io/library/alpine:3.19 LABEL maintainer="maintainers@gitea.io" EXPOSE 2222 3000 @@ -35,7 +51,8 @@ RUN apk --no-cache add \ gettext \ git \ curl \ - gnupg + gnupg \ + && rm -rf /var/cache/apk/* RUN addgroup \ -S -g 1000 \ @@ -51,21 +68,19 @@ RUN addgroup \ RUN mkdir -p /var/lib/gitea /etc/gitea RUN chown git:git /var/lib/gitea /etc/gitea -COPY docker/rootless / +COPY --from=build-env /tmp/local / COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini COPY --from=build-env /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh -RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-setup.sh /app/gitea/gitea /usr/local/bin/gitea /usr/local/bin/environment-to-ini -RUN chmod 644 /etc/profile.d/gitea_bash_autocomplete.sh -#git:git +# 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) +# 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"] @@ -73,4 +88,3 @@ WORKDIR /var/lib/gitea ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD [] - diff --git a/MAINTAINERS b/MAINTAINERS index 5f38c28beb6..eed87529a32 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -57,3 +57,7 @@ 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) diff --git a/Makefile b/Makefile index fd852cbaf93..8489520920c 100644 --- a/Makefile +++ b/Makefile @@ -23,36 +23,37 @@ SHASUM ?= shasum -a 256 HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes) COMMA := , -XGO_VERSION := go-1.21.x +XGO_VERSION := go-1.22.x -AIR_PACKAGE ?= github.com/cosmtrek/air@v1.44.0 +AIR_PACKAGE ?= github.com/cosmtrek/air@v1.49.0 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/cmd/editorconfig-checker@2.7.0 -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.5.0 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.1 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.6.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/cmd/golangci-lint@v1.56.1 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.11 -MISSPELL_PACKAGE ?= github.com/client9/misspell/cmd/misspell@v0.3.4 -SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.30.5 +MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.4.1 +SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@db51e79a0e37c572d8b59ae0c58bf2bbbbe53285 XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1.6.0 -GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.0.1 -ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.6.25 +GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.0.3 +ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.6.26 DOCKER_IMAGE ?= gitea/gitea DOCKER_TAG ?= latest DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) ifeq ($(HAS_GO), yes) - GOPATH ?= $(shell $(GO) env GOPATH) - export PATH := $(GOPATH)/bin:$(PATH) - CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766 CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS) endif -ifeq ($(OS), Windows_NT) - GOFLAGS := -v -buildmode=exe - EXECUTABLE ?= gitea.exe -else ifeq ($(OS), Windows) +ifeq ($(GOOS),windows) + IS_WINDOWS := yes +else ifeq ($(patsubst Windows%,Windows,$(OS)),Windows) + ifeq ($(GOOS),) + IS_WINDOWS := yes + endif +endif +ifeq ($(IS_WINDOWS),yes) GOFLAGS := -v -buildmode=exe EXECUTABLE ?= gitea.exe else @@ -111,13 +112,14 @@ LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64 GO_PACKAGES ?= $(filter-out code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration code.gitea.io/gitea/tests/e2e,$(shell $(GO) list ./... | grep -v /vendor/)) GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration code.gitea.io/gitea/tests/e2e,$(shell $(GO) list ./... | grep -v /vendor/)) +MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) FOMANTIC_WORK_DIR := web_src/fomantic WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) -WEBPACK_CONFIGS := webpack.config.js +WEBPACK_CONFIGS := webpack.config.js tailwind.config.js WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css -WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/img/webpack +WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts BINDATA_DEST := modules/public/bindata.go modules/options/bindata.go modules/templates/bindata.go BINDATA_HASH := $(addsuffix .hash,$(BINDATA_DEST)) @@ -142,6 +144,11 @@ TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(FOMAN GO_DIRS := build cmd models modules routers services tests WEB_DIRS := web_src/js web_src/css +ESLINT_FILES := web_src/js tools *.config.js tests/e2e +STYLELINT_FILES := web_src/css web_src/js/components/*.vue +SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) docs/content templates options/locale/locale_en-US.ini .github +EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.ini + GO_SOURCES := $(wildcard *.go) GO_SOURCES += $(shell find $(GO_DIRS) -type f -name "*.go" ! -path modules/options/bindata.go ! -path modules/public/bindata.go ! -path modules/templates/bindata.go) GO_SOURCES += $(GENERATED_GO_DEST) @@ -158,8 +165,8 @@ ifdef DEPS_PLAYWRIGHT endif SWAGGER_SPEC := templates/swagger/v1_json.tmpl -SWAGGER_SPEC_S_TMPL := s|"basePath": *"/api/v1"|"basePath": "{{AppSubUrl \| JSEscape \| Safe}}/api/v1"|g -SWAGGER_SPEC_S_JSON := s|"basePath": *"{{AppSubUrl \| JSEscape \| Safe}}/api/v1"|"basePath": "/api/v1"|g +SWAGGER_SPEC_S_TMPL := s|"basePath": *"/api/v1"|"basePath": "{{AppSubUrl \| JSEscape}}/api/v1"|g +SWAGGER_SPEC_S_JSON := s|"basePath": *"{{AppSubUrl \| JSEscape}}/api/v1"|"basePath": "/api/v1"|g SWAGGER_EXCLUDE := code.gitea.io/sdk SWAGGER_NEWLINE_COMMAND := -e '$$a\' @@ -167,10 +174,6 @@ TEST_MYSQL_HOST ?= mysql:3306 TEST_MYSQL_DBNAME ?= testgitea TEST_MYSQL_USERNAME ?= root TEST_MYSQL_PASSWORD ?= -TEST_MYSQL8_HOST ?= mysql8:3306 -TEST_MYSQL8_DBNAME ?= testgitea -TEST_MYSQL8_USERNAME ?= root -TEST_MYSQL8_PASSWORD ?= TEST_PGSQL_HOST ?= pgsql:5432 TEST_PGSQL_DBNAME ?= testgitea TEST_PGSQL_USERNAME ?= postgres @@ -219,6 +222,8 @@ help: @echo " - lint-swagger lint swagger files" @echo " - lint-templates lint template files" @echo " - lint-yaml lint yaml files" + @echo " - lint-spell lint spelling" + @echo " - lint-spell-fix lint spelling and fix issues" @echo " - checks run various consistency checks" @echo " - checks-frontend check frontend files" @echo " - checks-backend check backend files" @@ -226,6 +231,7 @@ help: @echo " - test-frontend test frontend files" @echo " - test-backend test backend files" @echo " - test-e2e[\#TestSpecificName] test end to end using playwright" + @echo " - update update js and py dependencies" @echo " - update-js update js dependencies" @echo " - update-py update py dependencies" @echo " - webpack build webpack files" @@ -277,16 +283,15 @@ clean-all: clean .PHONY: clean clean: - $(GO) clean -i ./... rm -rf $(EXECUTABLE) $(DIST) $(BINDATA_DEST) $(BINDATA_HASH) \ integrations*.test \ e2e*.test \ - tests/integration/gitea-integration-pgsql/ tests/integration/gitea-integration-mysql/ tests/integration/gitea-integration-mysql8/ tests/integration/gitea-integration-sqlite/ \ - tests/integration/gitea-integration-mssql/ tests/integration/indexers-mysql/ tests/integration/indexers-mysql8/ tests/integration/indexers-pgsql tests/integration/indexers-sqlite \ - tests/integration/indexers-mssql tests/mysql.ini tests/mysql8.ini tests/pgsql.ini tests/mssql.ini man/ \ - tests/e2e/gitea-e2e-pgsql/ tests/e2e/gitea-e2e-mysql/ tests/e2e/gitea-e2e-mysql8/ tests/e2e/gitea-e2e-sqlite/ \ - tests/e2e/gitea-e2e-mssql/ tests/e2e/indexers-mysql/ tests/e2e/indexers-mysql8/ tests/e2e/indexers-pgsql/ tests/e2e/indexers-sqlite/ \ - tests/e2e/indexers-mssql/ tests/e2e/reports/ tests/e2e/test-artifacts/ tests/e2e/test-snapshots/ + tests/integration/gitea-integration-* \ + tests/integration/indexers-* \ + tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \ + tests/e2e/gitea-e2e-*/ \ + tests/e2e/indexers-*/ \ + tests/e2e/reports/ tests/e2e/test-artifacts/ tests/e2e/test-snapshots/ .PHONY: fmt fmt: @@ -308,10 +313,6 @@ fmt-check: fmt exit 1; \ fi -.PHONY: misspell-check -misspell-check: - go run $(MISSPELL_PACKAGE) -error $(GO_DIRS) $(WEB_DIRS) - .PHONY: $(TAGS_EVIDENCE) $(TAGS_EVIDENCE): @mkdir -p $(MAKE_EVIDENCE_DIR) @@ -351,13 +352,13 @@ checks: checks-frontend checks-backend checks-frontend: lockfile-check svg-check .PHONY: checks-backend -checks-backend: tidy-check swagger-check fmt-check misspell-check swagger-validate security-check +checks-backend: tidy-check swagger-check fmt-check swagger-validate security-check .PHONY: lint -lint: lint-frontend lint-backend +lint: lint-frontend lint-backend lint-spell .PHONY: lint-fix -lint-fix: lint-frontend-fix lint-backend-fix +lint-fix: lint-frontend-fix lint-backend-fix lint-spell-fix .PHONY: lint-frontend lint-frontend: lint-js lint-css @@ -373,19 +374,19 @@ lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig .PHONY: lint-js lint-js: node_modules - npx eslint --color --max-warnings=0 --ext js,vue web_src/js build *.config.js tests/e2e + npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES) .PHONY: lint-js-fix lint-js-fix: node_modules - npx eslint --color --max-warnings=0 --ext js,vue web_src/js build *.config.js tests/e2e --fix + npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES) --fix .PHONY: lint-css lint-css: node_modules - npx stylelint --color --max-warnings=0 web_src/css web_src/js/components/*.vue + npx stylelint --color --max-warnings=0 $(STYLELINT_FILES) .PHONY: lint-css-fix lint-css-fix: node_modules - npx stylelint --color --max-warnings=0 web_src/css web_src/js/components/*.vue --fix + npx stylelint --color --max-warnings=0 $(STYLELINT_FILES) --fix .PHONY: lint-swagger lint-swagger: node_modules @@ -395,6 +396,14 @@ lint-swagger: node_modules lint-md: node_modules npx markdownlint docs *.md +.PHONY: lint-spell +lint-spell: + @go run $(MISSPELL_PACKAGE) -error $(SPELLCHECK_FILES) + +.PHONY: lint-spell-fix +lint-spell-fix: + @go run $(MISSPELL_PACKAGE) -w $(SPELLCHECK_FILES) + .PHONY: lint-go lint-go: $(GO) run $(GOLANGCI_LINT_PACKAGE) run @@ -418,14 +427,15 @@ lint-go-vet: .PHONY: lint-editorconfig lint-editorconfig: - $(GO) run $(EDITORCONFIG_CHECKER_PACKAGE) templates .github/workflows + @$(GO) run $(EDITORCONFIG_CHECKER_PACKAGE) $(EDITORCONFIG_FILES) .PHONY: lint-actions lint-actions: $(GO) run $(ACTIONLINT_PACKAGE) .PHONY: lint-templates -lint-templates: .venv +lint-templates: .venv node_modules + @node tools/lint-templates-svg.js @poetry run djlint $(shell find templates -type f -iname '*.tmpl') .PHONY: lint-yaml @@ -434,7 +444,7 @@ lint-yaml: .venv .PHONY: watch watch: - @bash build/watch.sh + @bash tools/watch.sh .PHONY: watch-frontend watch-frontend: node-check node_modules @@ -550,27 +560,6 @@ test-mysql\#%: integrations.mysql.test generate-ini-mysql .PHONY: test-mysql-migration test-mysql-migration: migrations.mysql.test migrations.individual.mysql.test -generate-ini-mysql8: - sed -e 's|{{TEST_MYSQL8_HOST}}|${TEST_MYSQL8_HOST}|g' \ - -e 's|{{TEST_MYSQL8_DBNAME}}|${TEST_MYSQL8_DBNAME}|g' \ - -e 's|{{TEST_MYSQL8_USERNAME}}|${TEST_MYSQL8_USERNAME}|g' \ - -e 's|{{TEST_MYSQL8_PASSWORD}}|${TEST_MYSQL8_PASSWORD}|g' \ - -e 's|{{REPO_TEST_DIR}}|${REPO_TEST_DIR}|g' \ - -e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ - -e 's|{{TEST_TYPE}}|$(or $(TEST_TYPE),integration)|g' \ - tests/mysql8.ini.tmpl > tests/mysql8.ini - -.PHONY: test-mysql8 -test-mysql8: integrations.mysql8.test generate-ini-mysql8 - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini ./integrations.mysql8.test - -.PHONY: test-mysql8\#% -test-mysql8\#%: integrations.mysql8.test generate-ini-mysql8 - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini ./integrations.mysql8.test -test.run $(subst .,/,$*) - -.PHONY: test-mysql8-migration -test-mysql8-migration: migrations.mysql8.test migrations.individual.mysql8.test - generate-ini-pgsql: sed -e 's|{{TEST_PGSQL_HOST}}|${TEST_PGSQL_HOST}|g' \ -e 's|{{TEST_PGSQL_DBNAME}}|${TEST_PGSQL_DBNAME}|g' \ @@ -615,8 +604,7 @@ test-mssql\#%: integrations.mssql.test generate-ini-mssql test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright -playwright: $(PLAYWRIGHT_DIR) - npm install --no-save @playwright/test +playwright: deps-frontend npx playwright install $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e% @@ -643,14 +631,6 @@ test-e2e-mysql: playwright e2e.mysql.test generate-ini-mysql test-e2e-mysql\#%: playwright e2e.mysql.test generate-ini-mysql GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./e2e.mysql.test -test.run TestE2e/$* -.PHONY: test-e2e-mysql8 -test-e2e-mysql8: playwright e2e.mysql8.test generate-ini-mysql8 - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini ./e2e.mysql8.test - -.PHONY: test-e2e-mysql8\#% -test-e2e-mysql8\#%: playwright e2e.mysql8.test generate-ini-mysql8 - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini ./e2e.mysql8.test -test.run TestE2e/$* - .PHONY: test-e2e-pgsql test-e2e-pgsql: playwright e2e.pgsql.test generate-ini-pgsql GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./e2e.pgsql.test @@ -694,9 +674,6 @@ integration-test-coverage-sqlite: integrations.cover.sqlite.test generate-ini-sq integrations.mysql.test: git-check $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mysql.test -integrations.mysql8.test: git-check $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mysql8.test - integrations.pgsql.test: git-check $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.pgsql.test @@ -717,11 +694,6 @@ migrations.mysql.test: $(GO_SOURCES) generate-ini-mysql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mysql.test GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./migrations.mysql.test -.PHONY: migrations.mysql8.test -migrations.mysql8.test: $(GO_SOURCES) generate-ini-mysql8 - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mysql8.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini ./migrations.mysql8.test - .PHONY: migrations.pgsql.test migrations.pgsql.test: $(GO_SOURCES) generate-ini-pgsql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.pgsql.test @@ -739,36 +711,23 @@ migrations.sqlite.test: $(GO_SOURCES) generate-ini-sqlite .PHONY: migrations.individual.mysql.test migrations.individual.mysql.test: $(GO_SOURCES) - for pkg in $(shell $(GO) list code.gitea.io/gitea/models/migrations/...); do \ - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' $$pkg; \ - done - -.PHONY: migrations.individual.mysql8.test -migrations.individual.mysql8.test: $(GO_SOURCES) - for pkg in $(shell $(GO) list code.gitea.io/gitea/models/migrations/...); do \ - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql8.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' $$pkg; \ - done + GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) -.PHONY: migrations.individual.mysql8.test\#% +.PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.pgsql.test migrations.individual.pgsql.test: $(GO_SOURCES) - for pkg in $(shell $(GO) list code.gitea.io/gitea/models/migrations/...); do \ - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' $$pkg; \ - done + GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.pgsql.test\#% migrations.individual.pgsql.test\#%: $(GO_SOURCES) generate-ini-pgsql GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* - .PHONY: migrations.individual.mssql.test migrations.individual.mssql.test: $(GO_SOURCES) generate-ini-mssql - for pkg in $(shell $(GO) list code.gitea.io/gitea/models/migrations/...); do \ - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' $$pkg -test.failfast; \ - done + GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.mssql.test\#% migrations.individual.mssql.test\#%: $(GO_SOURCES) generate-ini-mssql @@ -776,9 +735,7 @@ migrations.individual.mssql.test\#%: $(GO_SOURCES) generate-ini-mssql .PHONY: migrations.individual.sqlite.test migrations.individual.sqlite.test: $(GO_SOURCES) generate-ini-sqlite - for pkg in $(shell $(GO) list code.gitea.io/gitea/models/migrations/...); do \ - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' $$pkg; \ - done + GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite @@ -787,9 +744,6 @@ migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite e2e.mysql.test: $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mysql.test -e2e.mysql8.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mysql8.test - e2e.pgsql.test: $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.pgsql.test @@ -885,10 +839,6 @@ release-sources: | $(DIST_DIRS) release-docs: | $(DIST_DIRS) docs tar -czf $(DIST)/release/gitea-docs-$(VERSION).tar.gz -C ./docs . -.PHONY: docs -docs: - cd docs; bash scripts/trans-copy.sh; - .PHONY: deps deps: deps-frontend deps-backend deps-tools deps-py @@ -921,9 +871,12 @@ node_modules: package-lock.json @touch node_modules .venv: poetry.lock - poetry install + poetry install --no-root @touch .venv +.PHONY: update +update: update-js update-py + .PHONY: update-js update-js: node-check | node_modules npx updates -u -f package.json @@ -935,7 +888,7 @@ update-js: node-check | node_modules update-py: node-check | node_modules npx updates -u -f pyproject.toml rm -rf .venv poetry.lock - poetry install + poetry install --no-root @touch .venv .PHONY: fomantic @@ -944,6 +897,7 @@ fomantic: cd $(FOMANTIC_WORK_DIR) && npm install --no-save cp -f $(FOMANTIC_WORK_DIR)/theme.config.less $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/theme.config cp -rf $(FOMANTIC_WORK_DIR)/_site $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/ + $(SED_INPLACE) -e 's/ overrideBrowserslist\r/ overrideBrowserslist: ["defaults"]\r/g' $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/tasks/config/tasks.js cd $(FOMANTIC_WORK_DIR) && npx gulp -f node_modules/fomantic-ui/gulpfile.js build # fomantic uses "touchstart" as click event for some browsers, it's not ideal, so we force fomantic to always use "click" as click event $(SED_INPLACE) -e 's/clickEvent[ \t]*=/clickEvent = "click", unstableClickEvent =/g' $(FOMANTIC_WORK_DIR)/build/semantic.js @@ -962,7 +916,7 @@ $(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) package-lock.json .PHONY: svg svg: node-check | node_modules rm -rf $(SVG_DEST_DIR) - node build/generate-svg.js + node tools/generate-svg.js .PHONY: svg-check svg-check: svg @@ -1005,8 +959,8 @@ generate-gitignore: .PHONY: generate-images generate-images: | node_modules - npm install --no-save --no-package-lock fabric@5 imagemin-zopfli@7 - node build/generate-images.js $(TAGS) + npm install --no-save fabric@6.0.0-beta20 imagemin-zopfli@7 + node tools/generate-images.js $(TAGS) .PHONY: generate-manpage generate-manpage: @@ -1023,3 +977,8 @@ docker: # This endif closes the if at the top of the file endif + +# 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 index adf94dcc9a3..f5794491744 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,18 @@ -

- - Gitea - -

-

Gitea - Git with a cup of tea

- -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - Contribute with Gitpod - - - - - - - - - - -

- -

- View this document in Chinese -

+# 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://goreportcard.com/badge/code.gitea.io/gitea)](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card") +[![](https://pkg.go.dev/badge/code.gitea.io/gitea?status.svg)](https://pkg.go.dev/code.gitea.io/gitea "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") +[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/go-gitea/gitea) +[![](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea "Crowdin") +[![](https://badgen.net/https/api.tickgit.com/badgen/github.com/go-gitea/gitea/main)](https://www.tickgit.com/browse?repo=github.com/go-gitea/gitea&branch=main "TODOs") + +[View this document in Chinese](./README_ZH.md) ## Purpose @@ -62,11 +22,16 @@ painless way of setting up a self-hosted Git service. As Gitea is written in Go, it works across **all** the platforms and architectures that are supported by Go, including Linux, macOS, and Windows on x86, amd64, ARM and PowerPC architectures. -You can try it out using [the online demo](https://try.gitea.io/). This project has been [forked](https://blog.gitea.com/welcome-to-gitea/) from [Gogs](https://gogs.io) since November of 2016, but a lot has changed. +For online demonstrations, you can visit [try.gitea.io](https://try.gitea.io). + +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). + ## Building From the root of the source tree, run: @@ -84,25 +49,23 @@ The `build` target is split into two sub-targets: Internet connectivity is required to download the go and npm modules. When building from the official source tarballs which include pre-built frontend files, the `frontend` target will not be triggered, making it possible to build without Node.js. -Parallelism (`make -j `) is not supported. - More info: https://docs.gitea.com/installation/install-from-source ## Using ./gitea web -NOTE: If you're interested in using our APIs, we have experimental -support with [documentation](https://try.gitea.io/api/swagger). +> [!NOTE] +> If you're interested in using our APIs, we have experimental support with [documentation](https://try.gitea.io/api/swagger). ## Contributing Expected workflow is: Fork -> Patch -> Push -> Pull Request -NOTES: - -1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.** -2. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks! +> [!NOTE] +> +> 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.** +> 2. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks! ## Translating @@ -173,5 +136,5 @@ Looking for an overview of the interface? Check it out! |![Dashboard](https://dl.gitea.com/screenshots/home_timeline.png)|![User Profile](https://dl.gitea.com/screenshots/user_profile.png)|![Global Issues](https://dl.gitea.com/screenshots/global_issues.png)| |:---:|:---:|:---:| |![Branches](https://dl.gitea.com/screenshots/branches.png)|![Web Editor](https://dl.gitea.com/screenshots/web_editor.png)|![Activity](https://dl.gitea.com/screenshots/activity.png)| -|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png) -![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)| +|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png)| +|![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)| diff --git a/README_ZH.md b/README_ZH.md index 866b85e9991..726c4273a6b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,64 +1,28 @@ -

- - Gitea - -

-

Gitea - Git with a cup of tea

- -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - Contribute with Gitpod - - - - - - - - - - -

- -

- View this document in English -

+# 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://goreportcard.com/badge/code.gitea.io/gitea)](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card") +[![](https://pkg.go.dev/badge/code.gitea.io/gitea?status.svg)](https://pkg.go.dev/code.gitea.io/gitea "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") +[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/go-gitea/gitea) +[![](https://badges.crowdin.net/gitea/localized.svg)](https://crowdin.com/project/gitea "Crowdin") +[![](https://badgen.net/https/api.tickgit.com/badgen/github.com/go-gitea/gitea/main)](https://www.tickgit.com/browse?repo=github.com/go-gitea/gitea&branch=main "TODOs") + +[View this document in English](./README.md) ## 目标 Gitea 的首要目标是创建一个极易安装,运行非常快速,安装和使用体验良好的自建 Git 服务。我们采用 Go 作为后端语言,这使我们只要生成一个可执行程序即可。并且他还支持跨平台,支持 Linux, macOS 和 Windows 以及各种架构,除了 x86,amd64,还包括 ARM 和 PowerPC。 -如果您想试用一下,请访问 [在线Demo](https://try.gitea.io/)! +如果你想试用在线演示,请访问 [try.gitea.io](https://try.gitea.io/)。 + +如果你想使用免费的 Gitea 服务(有仓库数量限制),请访问 [gitea.com](https://gitea.com/user/login)。 + +如果你想在 Gitea Cloud 上快速部署你自己独享的 Gitea 实例,请访问 [cloud.gitea.com](https://cloud.gitea.com) 开始免费试用。 ## 提示 @@ -94,5 +58,5 @@ Fork -> Patch -> Push -> Pull Request |![Dashboard](https://dl.gitea.com/screenshots/home_timeline.png)|![User Profile](https://dl.gitea.com/screenshots/user_profile.png)|![Global Issues](https://dl.gitea.com/screenshots/global_issues.png)| |:---:|:---:|:---:| |![Branches](https://dl.gitea.com/screenshots/branches.png)|![Web Editor](https://dl.gitea.com/screenshots/web_editor.png)|![Activity](https://dl.gitea.com/screenshots/activity.png)| -|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png) -![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)| +|![New Migration](https://dl.gitea.com/screenshots/migration.png)|![Migrating](https://dl.gitea.com/screenshots/migration.gif)|![Pull Request View](https://image.ibb.co/e02dSb/6.png)| +|![Pull Request Dark](https://dl.gitea.com/screenshots/pull_requests_dark.png)|![Diff Review Dark](https://dl.gitea.com/screenshots/review_dark.png)|![Diff Dark](https://dl.gitea.com/screenshots/diff_dark.png)| diff --git a/SECURITY.md b/SECURITY.md index 5bee4a7f39f..4c7d437844d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ Please **DO NOT** file a public issue, instead send your report privately to `se ## Protecting Security Information -Due to the sensitive nature of security information, you can use below GPG public key encrypt your mail body. +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 June 24, 2024. diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 8a17148e1bc..be9022b6946 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -24,11 +24,21 @@ "path": "codeberg.org/gusted/mcaptcha/LICENSE", "licenseText": "Copyright © 2022 William Zijl\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, + { + "name": "connectrpc.com/connect", + "path": "connectrpc.com/connect/LICENSE", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2024 The Connect Authors\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, { "name": "dario.cat/mergo", "path": "dario.cat/mergo/LICENSE", "licenseText": "Copyright (c) 2013 Dario Castañé. All rights reserved.\nCopyright (c) 2012 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, + { + "name": "filippo.io/edwards25519", + "path": "filippo.io/edwards25519/LICENSE", + "licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "git.sr.ht/~mariusor/go-xsd-duration", "path": "git.sr.ht/~mariusor/go-xsd-duration/LICENSE", @@ -234,11 +244,6 @@ "path": "github.com/bradfitz/gomemcache/memcache/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, - { - "name": "github.com/bufbuild/connect-go", - "path": "github.com/bufbuild/connect-go/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2022 Buf Technologies, Inc.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" - }, { "name": "github.com/buildkite/terminal-to-html/v3", "path": "github.com/buildkite/terminal-to-html/v3/LICENSE", @@ -289,6 +294,11 @@ "path": "github.com/cpuguy83/go-md2man/v2/md2man/LICENSE.md", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Brian Goff\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, + { + "name": "github.com/cyphar/filepath-securejoin", + "path": "github.com/cyphar/filepath-securejoin/LICENSE", + "licenseText": "Copyright (C) 2014-2015 Docker Inc \u0026 Go Authors. All rights reserved.\nCopyright (C) 2017 SUSE LLC. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/davecgh/go-spew/spew", "path": "github.com/davecgh/go-spew/spew/LICENSE", @@ -530,8 +540,8 @@ "licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that You\nchanged the files; and\nYou must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\nIf the Work includes a \"NOTICE\" text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n5. Submission of Contributions.\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n6. Trademarks.\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty.\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n8. Limitation of Liability.\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets \"[]\" replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same \"printed page\" as the copyright notice for easier identification within\nthird-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/golang/protobuf", - "path": "github.com/golang/protobuf/LICENSE", + "name": "github.com/golang/protobuf/proto", + "path": "github.com/golang/protobuf/proto/LICENSE", "licenseText": "Copyright 2010 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" }, { @@ -540,8 +550,8 @@ "licenseText": "Copyright (c) 2011 The Snappy-Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/google/go-github/v53/github", - "path": "github.com/google/go-github/v53/github/LICENSE", + "name": "github.com/google/go-github/v57/github", + "path": "github.com/google/go-github/v57/github/LICENSE", "licenseText": "Copyright (c) 2013 The go-github AUTHORS. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { @@ -567,43 +577,33 @@ { "name": "github.com/gorilla/css/scanner", "path": "github.com/gorilla/css/scanner/LICENSE", - "licenseText": "Copyright (c) 2013, Gorilla web toolkit\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification,\nare permitted provided that the following conditions are met:\n\n Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n Redistributions in binary form must reproduce the above copyright notice, this\n list of conditions and the following disclaimer in the documentation and/or\n other materials provided with the distribution.\n\n Neither the name of the {organization} nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\nANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" }, { "name": "github.com/gorilla/feeds", "path": "github.com/gorilla/feeds/LICENSE", - "licenseText": "Copyright (c) 2013-2018 The Gorilla Feeds Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" }, { "name": "github.com/gorilla/mux", "path": "github.com/gorilla/mux/LICENSE", - "licenseText": "Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { "name": "github.com/gorilla/securecookie", "path": "github.com/gorilla/securecookie/LICENSE", - "licenseText": "Copyright (c) 2012 Rodrigo Moraes. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { "name": "github.com/gorilla/sessions", "path": "github.com/gorilla/sessions/LICENSE", - "licenseText": "Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" - }, - { - "name": "github.com/hashicorp/errwrap", - "path": "github.com/hashicorp/errwrap/LICENSE", - "licenseText": "Mozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n\n" + "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { "name": "github.com/hashicorp/go-cleanhttp", "path": "github.com/hashicorp/go-cleanhttp/LICENSE", "licenseText": "Mozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. \"Contributor\"\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n\n means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of\n version 1.1 or earlier of the License, but not also under the terms of\n a Secondary License.\n\n1.6. \"Executable Form\"\n\n means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n\n means a work that combines Covered Software with other material, in a\n separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n\n means this document.\n\n1.9. \"Licensable\"\n\n means having the right to grant, to the maximum extent possible, whether\n at the time of the initial grant or subsequently, any and all of the\n rights conveyed by this License.\n\n1.10. \"Modifications\"\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to,\n deletion from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. \"Patent Claims\" of a Contributor\n\n means any patent claim(s), including without limitation, method,\n process, and apparatus claims, in any patent Licensable by such\n Contributor that would be infringed, but for the grant of the License,\n by the making, using, selling, offering for sale, having made, import,\n or transfer of either its Contributions or its Contributor Version.\n\n1.12. \"Secondary License\"\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. \"Source Code Form\"\n\n means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, \"You\" includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, \"control\" means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or\n as part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its\n Contributions or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution\n become effective for each Contribution on the date the Contributor first\n distributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under\n this License. No additional rights or licenses will be implied from the\n distribution or licensing of Covered Software under this License.\n Notwithstanding Section 2.1(b) above, no patent license is granted by a\n Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party's\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of\n its Contributions.\n\n This License does not grant any rights in the trademarks, service marks,\n or logos of any Contributor (except as may be necessary to comply with\n the notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this\n License (see Section 10.2) or under the terms of a Secondary License (if\n permitted under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its\n Contributions are its original creation(s) or it has sufficient rights to\n grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under\n applicable copyright doctrines of fair use, fair dealing, or other\n equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under\n the terms of this License. You must inform recipients that the Source\n Code Form of the Covered Software is governed by the terms of this\n License, and how they can obtain a copy of this License. You may not\n attempt to alter or restrict the recipients' rights in the Source Code\n Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this\n License, or sublicense it under different terms, provided that the\n license for the Executable Form does not attempt to limit or alter the\n recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for\n the Covered Software. If the Larger Work is a combination of Covered\n Software with a work governed by one or more Secondary Licenses, and the\n Covered Software is not Incompatible With Secondary Licenses, this\n License permits You to additionally distribute such Covered Software\n under the terms of such Secondary License(s), so that the recipient of\n the Larger Work may, at their option, further distribute the Covered\n Software under the terms of either this License or such Secondary\n License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices\n (including copyright notices, patent notices, disclaimers of warranty, or\n limitations of liability) contained within the Source Code Form of the\n Covered Software, except that You may alter any license notices to the\n extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on\n behalf of any Contributor. You must make it absolutely clear that any\n such warranty, support, indemnity, or liability obligation is offered by\n You alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute,\n judicial order, or regulation then You must: (a) comply with the terms of\n this License to the maximum extent possible; and (b) describe the\n limitations and the code they affect. Such description must be placed in a\n text file included with all distributions of the Covered Software under\n this License. Except to the extent prohibited by statute or regulation,\n such description must be sufficiently detailed for a recipient of ordinary\n skill to be able to understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing\n basis, if such Contributor fails to notify You of the non-compliance by\n some reasonable means prior to 60 days after You have come back into\n compliance. Moreover, Your grants from a particular Contributor are\n reinstated on an ongoing basis if such Contributor notifies You of the\n non-compliance by some reasonable means, this is the first time You have\n received notice of non-compliance with this License from such\n Contributor, and You become compliant prior to 30 days after Your receipt\n of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions,\n counter-claims, and cross-claims) alleging that a Contributor Version\n directly or indirectly infringes any patent, then the rights granted to\n You by any and all Contributors for the Covered Software under Section\n 2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an \"as is\" basis,\n without warranty of any kind, either expressed, implied, or statutory,\n including, without limitation, warranties that the Covered Software is free\n of defects, merchantable, fit for a particular purpose or non-infringing.\n The entire risk as to the quality and performance of the Covered Software\n is with You. Should any Covered Software prove defective in any respect,\n You (not any Contributor) assume the cost of any necessary servicing,\n repair, or correction. This disclaimer of warranty constitutes an essential\n part of this License. No use of any Covered Software is authorized under\n this License except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from\n such party's negligence to the extent applicable law prohibits such\n limitation. Some jurisdictions do not allow the exclusion or limitation of\n incidental or consequential damages, so this exclusion and limitation may\n not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts\n of a jurisdiction where the defendant maintains its principal place of\n business and such litigation shall be governed by laws of that\n jurisdiction, without reference to its conflict-of-law provisions. Nothing\n in this Section shall prevent a party's ability to bring cross-claims or\n counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject\n matter hereof. If any provision of this License is held to be\n unenforceable, such provision shall be reformed only to the extent\n necessary to make it enforceable. Any law or regulation which provides that\n the language of a contract shall be construed against the drafter shall not\n be used to construe this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version\n of the License under which You originally received the Covered Software,\n or under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a\n modified version of this License if you rename the license and remove\n any references to the name of the license steward (except to note that\n such modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\n Licenses If You choose to distribute Source Code Form that is\n Incompatible With Secondary Licenses under the terms of this version of\n the License, the notice described in Exhibit B of this License must be\n attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file,\nthen You may include the notice in a location (such as a LICENSE file in a\nrelevant directory) where a recipient would be likely to look for such a\nnotice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n\n This Source Code Form is \"Incompatible\n With Secondary Licenses\", as defined by\n the Mozilla Public License, v. 2.0.\n\n" }, - { - "name": "github.com/hashicorp/go-multierror", - "path": "github.com/hashicorp/go-multierror/LICENSE", - "licenseText": "Mozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n" - }, { "name": "github.com/hashicorp/go-retryablehttp", "path": "github.com/hashicorp/go-retryablehttp/LICENSE", @@ -739,15 +739,10 @@ "path": "github.com/mattn/go-runewidth/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2016 Yasuhiro Matsumoto\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, - { - "name": "github.com/matttproud/golang_protobuf_extensions/pbutil", - "path": "github.com/matttproud/golang_protobuf_extensions/pbutil/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" - }, { "name": "github.com/meilisearch/meilisearch-go", "path": "github.com/meilisearch/meilisearch-go/LICENSE", - "licenseText": "MIT License\n\nCopyright (c) 2020-2022 Meili SAS\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + "licenseText": "MIT License\n\nCopyright (c) 2020-2024 Meili SAS\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { "name": "github.com/mholt/acmez", @@ -904,11 +899,6 @@ "path": "github.com/rivo/uniseg/LICENSE.txt", "licenseText": "MIT License\n\nCopyright (c) 2019 Oliver Kuederle\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, - { - "name": "github.com/robfig/cron", - "path": "github.com/robfig/cron/LICENSE", - "licenseText": "Copyright (C) 2012 Rob Figueiredo\nAll Rights Reserved.\n\nMIT LICENSE\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, { "name": "github.com/robfig/cron/v3", "path": "github.com/robfig/cron/v3/LICENSE", @@ -1081,7 +1071,7 @@ }, { "name": "go.uber.org/zap", - "path": "go.uber.org/zap/LICENSE.txt", + "path": "go.uber.org/zap/LICENSE", "licenseText": "Copyright (c) 2016-2017 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, { diff --git a/cmd/actions.go b/cmd/actions.go index 052afb9ebc3..f582c16c81c 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -15,9 +15,8 @@ import ( var ( // CmdActions represents the available actions sub-commands. CmdActions = &cli.Command{ - Name: "actions", - Usage: "", - Description: "Commands for managing Gitea Actions", + Name: "actions", + Usage: "Manage Gitea Actions", Subcommands: []*cli.Command{ subcmdActionsGenRunnerToken, }, @@ -51,6 +50,6 @@ func runGenerateActionsRunnerToken(c *cli.Context) error { if extra.HasError() { return handleCliResponseExtra(extra) } - _, _ = fmt.Printf("%s\n", respText) + _, _ = fmt.Printf("%s\n", respText.Text) return nil } diff --git a/cmd/admin.go b/cmd/admin.go index a4f48b05130..6c9480e76eb 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -6,26 +6,14 @@ package cmd import ( "context" - "errors" "fmt" - "net/url" - "os" - "strings" - "text/tabwriter" - asymkey_model "code.gitea.io/gitea/models/asymkey" - auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/util" - auth_service "code.gitea.io/gitea/services/auth" - "code.gitea.io/gitea/services/auth/source/oauth2" - "code.gitea.io/gitea/services/auth/source/smtp" - repo_service "code.gitea.io/gitea/services/repository" "github.com/urfave/cli/v2" ) @@ -34,7 +22,7 @@ var ( // CmdAdmin represents the available admin sub-command. CmdAdmin = &cli.Command{ Name: "admin", - Usage: "Command line interface to perform common administrative operations", + Usage: "Perform common administrative operations", Subcommands: []*cli.Command{ subcmdUser, subcmdRepoSyncReleases, @@ -59,28 +47,16 @@ var ( }, } - microcmdRegenHooks = &cli.Command{ - Name: "hooks", - Usage: "Regenerate git-hooks", - Action: runRegenerateHooks, - } - - microcmdRegenKeys = &cli.Command{ - Name: "keys", - Usage: "Regenerate authorized_keys file", - Action: runRegenerateKeys, - } - subcmdAuth = &cli.Command{ Name: "auth", Usage: "Modify external auth providers", Subcommands: []*cli.Command{ microcmdAuthAddOauth, microcmdAuthUpdateOauth, - cmdAuthAddLdapBindDn, - cmdAuthUpdateLdapBindDn, - cmdAuthAddLdapSimpleAuth, - cmdAuthUpdateLdapSimpleAuth, + microcmdAuthAddLdapBindDn, + microcmdAuthUpdateLdapBindDn, + microcmdAuthAddLdapSimpleAuth, + microcmdAuthUpdateLdapSimpleAuth, microcmdAuthAddSMTP, microcmdAuthUpdateSMTP, microcmdAuthList, @@ -88,170 +64,6 @@ var ( }, } - microcmdAuthList = &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", - }, - }, - } - - idFlag = &cli.Int64Flag{ - Name: "id", - Usage: "ID of authentication source", - } - - microcmdAuthDelete = &cli.Command{ - Name: "delete", - Usage: "Delete specific auth source", - Flags: []cli.Flag{idFlag}, - Action: runDeleteAuth, - } - - oauthCLIFlags = []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: "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", - }, - } - - microcmdAuthUpdateOauth = &cli.Command{ - Name: "update-oauth", - Usage: "Update existing Oauth authentication source", - Action: runUpdateOauth, - Flags: append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...), - } - - microcmdAuthAddOauth = &cli.Command{ - Name: "add-oauth", - Usage: "Add new Oauth authentication source", - Action: runAddOauth, - Flags: oauthCLIFlags, - } - subcmdSendMail = &cli.Command{ Name: "sendmail", Usage: "Send a message to all users", @@ -275,75 +87,9 @@ var ( }, } - smtpCLIFlags = []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.", - Value: true, - }, - &cli.BoolFlag{ - Name: "skip-verify", - Usage: "Skip TLS verify.", - Value: true, - }, - &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.", - Value: true, - }, - &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.", - Value: true, - }, - &cli.BoolFlag{ - Name: "active", - Usage: "This Authentication Source is Activated.", - Value: true, - }, - } - - microcmdAuthAddSMTP = &cli.Command{ - Name: "add-smtp", - Usage: "Add new SMTP authentication source", - Action: runAddSMTP, - Flags: smtpCLIFlags, - } - - microcmdAuthUpdateSMTP = &cli.Command{ - Name: "update-smtp", - Usage: "Update existing SMTP authentication source", - Action: runUpdateSMTP, - Flags: append(smtpCLIFlags[:1], append([]cli.Flag{idFlag}, smtpCLIFlags[1:]...)...), + idFlag = &cli.Int64Flag{ + Name: "id", + Usage: "ID of authentication source", } ) @@ -377,7 +123,7 @@ func runRepoSyncReleases(_ *cli.Context) error { log.Trace("Processing next %d repos of %d", len(repos), count) for _, repo := range repos { log.Trace("Synchronizing repo %s with path %s", repo.FullName(), repo.RepoPath()) - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { log.Warn("OpenRepository: %v", err) continue @@ -389,7 +135,7 @@ func runRepoSyncReleases(_ *cli.Context) error { } log.Trace(" currentNumReleases is %d, running SyncReleasesWithTags", oldnum) - if err = repo_module.SyncReleasesWithTags(repo, gitRepo); err != nil { + if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { log.Warn(" SyncReleasesWithTags: %v", err) gitRepo.Close() continue @@ -412,359 +158,11 @@ func runRepoSyncReleases(_ *cli.Context) error { } func getReleaseCount(ctx context.Context, id int64) (int64, error) { - return repo_model.GetReleaseCountByRepoID( + return db.Count[repo_model.Release]( ctx, - id, repo_model.FindReleasesOptions{ + RepoID: id, IncludeTags: true, }, ) } - -func runRegenerateHooks(_ *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext()) -} - -func runRegenerateKeys(_ *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - return asymkey_model.RewriteAllPublicKeys() -} - -func parseOAuth2Config(c *cli.Context) *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"), - SkipLocalTwoFA: c.Bool("skip-local-2fa"), - 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"), - } -} - -func runAddOauth(c *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := 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 auth_model.CreateSource(&auth_model.Source{ - Type: auth_model.OAuth2, - Name: c.String("name"), - IsActive: true, - Cfg: config, - }) -} - -func runUpdateOauth(c *cli.Context) error { - if !c.IsSet("id") { - return fmt.Errorf("--id flag is missing") - } - - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - - source, err := auth_model.GetSourceByID(c.Int64("id")) - if err != nil { - return err - } - - oAuth2Config := source.Cfg.(*oauth2.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") - } - - // 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 - - return auth_model.UpdateSource(source) -} - -func parseSMTPConfig(c *cli.Context, 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") - } - if c.IsSet("skip-local-2fa") { - conf.SkipLocalTwoFA = c.Bool("skip-local-2fa") - } - return nil -} - -func runAddSMTP(c *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := 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 auth_model.CreateSource(&auth_model.Source{ - Type: auth_model.SMTP, - Name: c.String("name"), - IsActive: active, - Cfg: &smtpConfig, - }) -} - -func runUpdateSMTP(c *cli.Context) error { - if !c.IsSet("id") { - return fmt.Errorf("--id flag is missing") - } - - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - - source, err := auth_model.GetSourceByID(c.Int64("id")) - if err != nil { - return err - } - - smtpConfig := source.Cfg.(*smtp.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 - - return auth_model.UpdateSource(source) -} - -func runListAuth(c *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - - authSources, err := auth_model.Sources() - 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(c *cli.Context) error { - if !c.IsSet("id") { - return fmt.Errorf("--id flag is missing") - } - - ctx, cancel := installSignals() - defer cancel() - - if err := initDB(ctx); err != nil { - return err - } - - source, err := auth_model.GetSourceByID(c.Int64("id")) - if err != nil { - return err - } - - return auth_service.DeleteSource(source) -} diff --git a/cmd/admin_auth.go b/cmd/admin_auth.go new file mode 100644 index 00000000000..ec92e342d4d --- /dev/null +++ b/cmd/admin_auth.go @@ -0,0 +1,110 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + auth_service "code.gitea.io/gitea/services/auth" + + "github.com/urfave/cli/v2" +) + +var ( + microcmdAuthDelete = &cli.Command{ + Name: "delete", + Usage: "Delete specific auth source", + Flags: []cli.Flag{idFlag}, + Action: runDeleteAuth, + } + microcmdAuthList = &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(c *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + 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(c *cli.Context) error { + if !c.IsSet("id") { + return fmt.Errorf("--id flag is missing") + } + + ctx, cancel := installSignals() + defer cancel() + + 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 index cfa1a232353..e3c81809f8d 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -17,9 +17,9 @@ import ( type ( authService struct { initDB func(ctx context.Context) error - createAuthSource func(*auth.Source) error - updateAuthSource func(*auth.Source) error - getAuthSourceByID func(id int64) (*auth.Source, 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) } ) @@ -132,10 +132,10 @@ var ( ldapSimpleAuthCLIFlags = append(commonLdapCLIFlags, &cli.StringFlag{ Name: "user-dn", - Usage: "The user’s DN.", + Usage: "The user's DN.", }) - cmdAuthAddLdapBindDn = &cli.Command{ + microcmdAuthAddLdapBindDn = &cli.Command{ Name: "add-ldap", Usage: "Add new LDAP (via Bind DN) authentication source", Action: func(c *cli.Context) error { @@ -144,7 +144,7 @@ var ( Flags: ldapBindDnCLIFlags, } - cmdAuthUpdateLdapBindDn = &cli.Command{ + microcmdAuthUpdateLdapBindDn = &cli.Command{ Name: "update-ldap", Usage: "Update existing LDAP (via Bind DN) authentication source", Action: func(c *cli.Context) error { @@ -153,7 +153,7 @@ var ( Flags: append([]cli.Flag{idFlag}, ldapBindDnCLIFlags...), } - cmdAuthAddLdapSimpleAuth = &cli.Command{ + microcmdAuthAddLdapSimpleAuth = &cli.Command{ Name: "add-ldap-simple", Usage: "Add new LDAP (simple auth) authentication source", Action: func(c *cli.Context) error { @@ -162,7 +162,7 @@ var ( Flags: ldapSimpleAuthCLIFlags, } - cmdAuthUpdateLdapSimpleAuth = &cli.Command{ + microcmdAuthUpdateLdapSimpleAuth = &cli.Command{ Name: "update-ldap-simple", Usage: "Update existing LDAP (simple auth) authentication source", Action: func(c *cli.Context) error { @@ -289,12 +289,12 @@ func findLdapSecurityProtocolByName(name string) (ldap.SecurityProtocol, bool) { // 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(c *cli.Context, authType auth.Type) (*auth.Source, error) { +func (a *authService) getAuthSource(ctx context.Context, c *cli.Context, authType auth.Type) (*auth.Source, error) { if err := argsSet(c, "id"); err != nil { return nil, err } - authSource, err := a.getAuthSourceByID(c.Int64("id")) + authSource, err := a.getAuthSourceByID(ctx, c.Int64("id")) if err != nil { return nil, err } @@ -332,7 +332,7 @@ func (a *authService) addLdapBindDn(c *cli.Context) error { return err } - return a.createAuthSource(authSource) + return a.createAuthSource(ctx, authSource) } // updateLdapBindDn updates a new LDAP via Bind DN authentication source. @@ -344,7 +344,7 @@ func (a *authService) updateLdapBindDn(c *cli.Context) error { return err } - authSource, err := a.getAuthSource(c, auth.LDAP) + authSource, err := a.getAuthSource(ctx, c, auth.LDAP) if err != nil { return err } @@ -354,7 +354,7 @@ func (a *authService) updateLdapBindDn(c *cli.Context) error { return err } - return a.updateAuthSource(authSource) + return a.updateAuthSource(ctx, authSource) } // addLdapSimpleAuth adds a new LDAP (simple auth) authentication source. @@ -383,7 +383,7 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error { return err } - return a.createAuthSource(authSource) + return a.createAuthSource(ctx, authSource) } // updateLdapBindDn updates a new LDAP (simple auth) authentication source. @@ -395,7 +395,7 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { return err } - authSource, err := a.getAuthSource(c, auth.DLDAP) + authSource, err := a.getAuthSource(ctx, c, auth.DLDAP) if err != nil { return err } @@ -405,5 +405,5 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { return err } - return a.updateAuthSource(authSource) + return a.updateAuthSource(ctx, authSource) } diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go index 210a6463c31..7791f3a9cc1 100644 --- a/cmd/admin_auth_ldap_test.go +++ b/cmd/admin_auth_ldap_test.go @@ -210,15 +210,15 @@ func TestAddLdapBindDn(t *testing.T) { initDB: func(context.Context) error { return nil }, - createAuthSource: func(authSource *auth.Source) error { + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { createdAuthSource = authSource return nil }, - updateAuthSource: func(authSource *auth.Source) error { + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { assert.FailNow(t, "case %d: should not call updateAuthSource", n) return nil }, - getAuthSourceByID: func(id int64) (*auth.Source, error) { + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "case %d: should not call getAuthSourceByID", n) return nil, nil }, @@ -226,7 +226,7 @@ func TestAddLdapBindDn(t *testing.T) { // Create a copy of command to test app := cli.NewApp() - app.Flags = cmdAuthAddLdapBindDn.Flags + app.Flags = microcmdAuthAddLdapBindDn.Flags app.Action = service.addLdapBindDn // Run it @@ -441,15 +441,15 @@ func TestAddLdapSimpleAuth(t *testing.T) { initDB: func(context.Context) error { return nil }, - createAuthSource: func(authSource *auth.Source) error { + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { createdAuthSource = authSource return nil }, - updateAuthSource: func(authSource *auth.Source) error { + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { assert.FailNow(t, "case %d: should not call updateAuthSource", n) return nil }, - getAuthSourceByID: func(id int64) (*auth.Source, error) { + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "case %d: should not call getAuthSourceByID", n) return nil, nil }, @@ -457,7 +457,7 @@ func TestAddLdapSimpleAuth(t *testing.T) { // Create a copy of command to test app := cli.NewApp() - app.Flags = cmdAuthAddLdapSimpleAuth.Flags + app.Flags = microcmdAuthAddLdapSimpleAuth.Flags app.Action = service.addLdapSimpleAuth // Run it @@ -896,15 +896,15 @@ func TestUpdateLdapBindDn(t *testing.T) { initDB: func(context.Context) error { return nil }, - createAuthSource: func(authSource *auth.Source) error { + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { assert.FailNow(t, "case %d: should not call createAuthSource", n) return nil }, - updateAuthSource: func(authSource *auth.Source) error { + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { updatedAuthSource = authSource return nil }, - getAuthSourceByID: func(id int64) (*auth.Source, error) { + 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) } @@ -920,7 +920,7 @@ func TestUpdateLdapBindDn(t *testing.T) { // Create a copy of command to test app := cli.NewApp() - app.Flags = cmdAuthUpdateLdapBindDn.Flags + app.Flags = microcmdAuthUpdateLdapBindDn.Flags app.Action = service.updateLdapBindDn // Run it @@ -1286,15 +1286,15 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { initDB: func(context.Context) error { return nil }, - createAuthSource: func(authSource *auth.Source) error { + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { assert.FailNow(t, "case %d: should not call createAuthSource", n) return nil }, - updateAuthSource: func(authSource *auth.Source) error { + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { updatedAuthSource = authSource return nil }, - getAuthSourceByID: func(id int64) (*auth.Source, error) { + 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) } @@ -1310,7 +1310,7 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { // Create a copy of command to test app := cli.NewApp() - app.Flags = cmdAuthUpdateLdapSimpleAuth.Flags + app.Flags = microcmdAuthUpdateLdapSimpleAuth.Flags app.Action = service.updateLdapSimpleAuth // Run it diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go new file mode 100644 index 00000000000..c151c0af277 --- /dev/null +++ b/cmd/admin_auth_oauth.go @@ -0,0 +1,298 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "net/url" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" + + "github.com/urfave/cli/v2" +) + +var ( + oauthCLIFlags = []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: "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", + }, + } + + microcmdAuthAddOauth = &cli.Command{ + Name: "add-oauth", + Usage: "Add new Oauth authentication source", + Action: runAddOauth, + Flags: oauthCLIFlags, + } + + microcmdAuthUpdateOauth = &cli.Command{ + Name: "update-oauth", + Usage: "Update existing Oauth authentication source", + Action: runUpdateOauth, + Flags: append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...), + } +) + +func parseOAuth2Config(c *cli.Context) *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"), + SkipLocalTwoFA: c.Bool("skip-local-2fa"), + 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"), + } +} + +func runAddOauth(c *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + if err := 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 auth_model.CreateSource(ctx, &auth_model.Source{ + Type: auth_model.OAuth2, + Name: c.String("name"), + IsActive: true, + Cfg: config, + }) +} + +func runUpdateOauth(c *cli.Context) error { + if !c.IsSet("id") { + return fmt.Errorf("--id flag is missing") + } + + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + source, err := auth_model.GetSourceByID(ctx, c.Int64("id")) + if err != nil { + return err + } + + oAuth2Config := source.Cfg.(*oauth2.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") + } + + // 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 + + return auth_model.UpdateSource(ctx, source) +} diff --git a/cmd/admin_auth_stmp.go b/cmd/admin_auth_stmp.go new file mode 100644 index 00000000000..58a6e2ac220 --- /dev/null +++ b/cmd/admin_auth_stmp.go @@ -0,0 +1,201 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "fmt" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/auth/source/smtp" + + "github.com/urfave/cli/v2" +) + +var ( + smtpCLIFlags = []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.", + Value: true, + }, + &cli.BoolFlag{ + Name: "skip-verify", + Usage: "Skip TLS verify.", + Value: true, + }, + &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.", + Value: true, + }, + &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.", + Value: true, + }, + &cli.BoolFlag{ + Name: "active", + Usage: "This Authentication Source is Activated.", + Value: true, + }, + } + + microcmdAuthAddSMTP = &cli.Command{ + Name: "add-smtp", + Usage: "Add new SMTP authentication source", + Action: runAddSMTP, + Flags: smtpCLIFlags, + } + + microcmdAuthUpdateSMTP = &cli.Command{ + Name: "update-smtp", + Usage: "Update existing SMTP authentication source", + Action: runUpdateSMTP, + Flags: append(smtpCLIFlags[:1], append([]cli.Flag{idFlag}, smtpCLIFlags[1:]...)...), + } +) + +func parseSMTPConfig(c *cli.Context, 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") + } + if c.IsSet("skip-local-2fa") { + conf.SkipLocalTwoFA = c.Bool("skip-local-2fa") + } + return nil +} + +func runAddSMTP(c *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + if err := 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 auth_model.CreateSource(ctx, &auth_model.Source{ + Type: auth_model.SMTP, + Name: c.String("name"), + IsActive: active, + Cfg: &smtpConfig, + }) +} + +func runUpdateSMTP(c *cli.Context) error { + if !c.IsSet("id") { + return fmt.Errorf("--id flag is missing") + } + + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + source, err := auth_model.GetSourceByID(ctx, c.Int64("id")) + if err != nil { + return err + } + + smtpConfig := source.Cfg.(*smtp.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 + + return auth_model.UpdateSource(ctx, source) +} diff --git a/cmd/admin_regenerate.go b/cmd/admin_regenerate.go new file mode 100644 index 00000000000..ab769f6d0c6 --- /dev/null +++ b/cmd/admin_regenerate.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "code.gitea.io/gitea/modules/graceful" + asymkey_service "code.gitea.io/gitea/services/asymkey" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/urfave/cli/v2" +) + +var ( + microcmdRegenHooks = &cli.Command{ + Name: "hooks", + Usage: "Regenerate git-hooks", + Action: runRegenerateHooks, + } + + microcmdRegenKeys = &cli.Command{ + Name: "keys", + Usage: "Regenerate authorized_keys file", + Action: runRegenerateKeys, + } +) + +func runRegenerateHooks(_ *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext()) +} + +func runRegenerateKeys(_ *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + return asymkey_service.RewriteAllPublicKeys(ctx) +} diff --git a/cmd/admin_user_change_password.go b/cmd/admin_user_change_password.go index eebbfb3b671..824d66d1125 100644 --- a/cmd/admin_user_change_password.go +++ b/cmd/admin_user_change_password.go @@ -4,13 +4,14 @@ package cmd import ( - "context" "errors" "fmt" user_model "code.gitea.io/gitea/models/user" - pwd "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + user_service "code.gitea.io/gitea/services/user" "github.com/urfave/cli/v2" ) @@ -32,6 +33,10 @@ var microcmdUserChangePassword = &cli.Command{ Value: "", Usage: "New password to set for user", }, + &cli.BoolFlag{ + Name: "must-change-password", + Usage: "User must change password", + }, }, } @@ -46,31 +51,32 @@ func runChangePassword(c *cli.Context) error { if err := initDB(ctx); err != nil { return err } - if len(c.String("password")) < setting.MinPasswordLength { - return fmt.Errorf("Password is not long enough. Needs to be at least %d", setting.MinPasswordLength) - } - if !pwd.IsComplexEnough(c.String("password")) { - return errors.New("Password does not meet complexity requirements") - } - pwned, err := pwd.IsPwned(context.Background(), c.String("password")) + user, err := user_model.GetUserByName(ctx, c.String("username")) if err != nil { return err } - if pwned { - return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords") - } - uname := c.String("username") - user, err := user_model.GetUserByName(ctx, uname) - if err != nil { - return err - } - if err = user.SetPassword(c.String("password")); err != nil { - return err + + var mustChangePassword optional.Option[bool] + if c.IsSet("must-change-password") { + mustChangePassword = optional.Some(c.Bool("must-change-password")) } - if err = user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil { - return err + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(c.String("password")), + MustChangePassword: mustChangePassword, + } + 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", 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 you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords") + default: + return err + } } fmt.Printf("%s's password has been successfully updated!\n", user.Name) diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index fefe18d39c5..a257ce21c8d 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -10,8 +10,8 @@ import ( auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" pwd "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "github.com/urfave/cli/v2" ) @@ -123,10 +123,10 @@ func runCreateUser(c *cli.Context) error { changePassword = c.Bool("must-change-password") } - restricted := util.OptionalBoolNone + restricted := optional.None[bool]() if c.IsSet("restricted") { - restricted = util.OptionalBoolOf(c.Bool("restricted")) + restricted = optional.Some(c.Bool("restricted")) } // default user visibility in app.ini @@ -142,7 +142,7 @@ func runCreateUser(c *cli.Context) error { } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), IsRestricted: restricted, } diff --git a/cmd/doctor.go b/cmd/doctor.go index d040a3af1c8..e433f4adc5d 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -14,14 +14,28 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/migrations" migrate_base "code.gitea.io/gitea/models/migrations/base" - "code.gitea.io/gitea/modules/doctor" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/doctor" "github.com/urfave/cli/v2" "xorm.io/xorm" ) +// CmdDoctor represents the available doctor sub-command. +var CmdDoctor = &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.", + + Subcommands: []*cli.Command{ + cmdDoctorCheck, + cmdRecreateTable, + cmdDoctorConvert, + }, +} + var cmdDoctorCheck = &cli.Command{ Name: "check", Usage: "Diagnose and optionally fix problems", @@ -60,19 +74,6 @@ var cmdDoctorCheck = &cli.Command{ }, } -// CmdDoctor represents the available doctor sub-command. -var CmdDoctor = &cli.Command{ - Name: "doctor", - 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.", - - Subcommands: []*cli.Command{ - cmdDoctorCheck, - cmdRecreateTable, - cmdDoctorConvert, - }, -} - var cmdRecreateTable = &cli.Command{ Name: "recreate-table", Usage: "Recreate tables from XORM definitions and copy the data.", @@ -177,6 +178,7 @@ func runDoctorCheck(ctx *cli.Context) error { if ctx.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{'*'}) @@ -192,26 +194,20 @@ func runDoctorCheck(ctx *cli.Context) error { var checks []*doctor.Check if ctx.Bool("all") { - checks = doctor.Checks + checks = make([]*doctor.Check, len(doctor.Checks)) + copy(checks, doctor.Checks) } else if ctx.IsSet("run") { addDefault := ctx.Bool("default") - names := ctx.StringSlice("run") - for i, name := range names { - names[i] = strings.ToLower(strings.TrimSpace(name)) - } - + runNamesSet := container.SetOf(ctx.StringSlice("run")...) for _, check := range doctor.Checks { - if addDefault && check.IsDefault { + if (addDefault && check.IsDefault) || runNamesSet.Contains(check.Name) { checks = append(checks, check) - continue - } - for _, name := range names { - if name == check.Name { - checks = append(checks, check) - break - } + 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 { @@ -219,6 +215,5 @@ func runDoctorCheck(ctx *cli.Context) error { } } } - return doctor.RunChecks(stdCtx, colorize, ctx.Bool("fix"), checks) } diff --git a/cmd/doctor_convert.go b/cmd/doctor_convert.go index 2385f23e521..48c835ad0e2 100644 --- a/cmd/doctor_convert.go +++ b/cmd/doctor_convert.go @@ -37,8 +37,8 @@ func runDoctorConvert(ctx *cli.Context) error { switch { case setting.Database.Type.IsMySQL(): - if err := db.ConvertUtf8ToUtf8mb4(); err != nil { - log.Fatal("Failed to convert database from utf8 to utf8mb4: %v", err) + if err := db.ConvertDatabaseTable(); err != nil { + log.Fatal("Failed to convert database & table: %v", err) return err } fmt.Println("Converted successfully, please confirm your database's character set is now utf8mb4") diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 00000000000..3e1ff299c5a --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,33 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/doctor" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +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.NewApp() + app.Commands = []*cli.Command{cmdDoctorCheck} + err := app.Run([]string{"./gitea", "check", "--run", "test-check"}) + assert.NoError(t, err) + err = app.Run([]string{"./gitea", "check", "--run", "no-such"}) + assert.ErrorContains(t, err, `unknown checks: "no-such"`) + err = app.Run([]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 index 97f292ae096..da0a51d9ce9 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -6,14 +6,13 @@ package cmd import ( "fmt" - "io" "os" "path" "path/filepath" "strings" - "time" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/dump" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -25,89 +24,17 @@ import ( "github.com/urfave/cli/v2" ) -func addReader(w archiver.Writer, r io.ReadCloser, info os.FileInfo, customName string, verbose bool) error { - if verbose { - log.Info("Adding file %s", customName) - } - - return w.Write(archiver.File{ - FileInfo: archiver.FileInfo{ - FileInfo: info, - CustomName: customName, - }, - ReadCloser: r, - }) -} - -func addFile(w archiver.Writer, filePath, absPath string, verbose bool) error { - file, err := os.Open(absPath) - if err != nil { - return err - } - defer file.Close() - fileInfo, err := file.Stat() - if err != nil { - return err - } - - return addReader(w, file, fileInfo, filePath, verbose) -} - -func isSubdir(upper, lower string) (bool, error) { - if relPath, err := filepath.Rel(upper, lower); err != nil { - return false, err - } else if relPath == "." || !strings.HasPrefix(relPath, ".") { - return true, nil - } - return false, nil -} - -type outputType struct { - Enum []string - Default string - selected string -} - -func (o outputType) Join() string { - return strings.Join(o.Enum, ", ") -} - -func (o *outputType) Set(value string) error { - for _, enum := range o.Enum { - if enum == value { - o.selected = value - return nil - } - } - - return fmt.Errorf("allowed values are %s", o.Join()) -} - -func (o outputType) String() string { - if o.selected == "" { - return o.Default - } - return o.selected -} - -var outputTypeEnum = &outputType{ - Enum: []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"}, - Default: "zip", -} - // CmdDump represents the available dump sub-command. var CmdDump = &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, + 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"}, - Value: fmt.Sprintf("gitea-dump-%d.zip", time.Now().Unix()), - Usage: "Name of the dump file which will be created. Supply '-' for stdout. See type for available types.", + 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", @@ -160,63 +87,51 @@ It can be used for backup and capture Gitea server image to send to maintainer`, Name: "skip-index", Usage: "Skip bleve index data", }, - &cli.GenericFlag{ + &cli.StringFlag{ Name: "type", - Value: outputTypeEnum, - Usage: fmt.Sprintf("Dump output format: %s", outputTypeEnum.Join()), + Usage: fmt.Sprintf(`Dump output format, default to "zip", supported types: %s`, strings.Join(dump.SupportedOutputTypes, ", ")), }, }, } func fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, format+"\n", args...) log.Fatal(format, args...) } func runDump(ctx *cli.Context) error { - var file *os.File - fileName := ctx.String("file") - outType := ctx.String("type") - if fileName == "-" { - file = os.Stdout - setupConsoleLogger(log.FATAL, log.CanColorStderr, os.Stderr) - } else { - for _, suffix := range outputTypeEnum.Enum { - if strings.HasSuffix(fileName, "."+suffix) { - fileName = strings.TrimSuffix(fileName, "."+suffix) - break - } - } - fileName += "." + outType - } setting.MustInstalled() - // make sure we are logging to the console no matter what the configuration tells us do to - // FIXME: don't use CfgProvider directly - if _, err := setting.CfgProvider.Section("log").NewKey("MODE", "console"); err != nil { - fatal("Setting logging mode to console failed: %v", err) + quite := ctx.Bool("quiet") + verbose := ctx.Bool("verbose") + if verbose && quite { + fatal("Option --quiet and --verbose cannot both be set") } - if _, err := setting.CfgProvider.Section("log.console").NewKey("STDERR", "true"); err != nil { - fatal("Setting console logger to stderr failed: %v", err) + + // outFileName is either "-" or a file name (will be made absolute) + outFileName, outType := dump.PrepareFileNameAndType(ctx.String("file"), ctx.String("type")) + if outType == "" { + fatal("Invalid output type") } - // Set loglevel to Warn if quiet-mode is requested - if ctx.Bool("quiet") { - if _, err := setting.CfgProvider.Section("log.console").NewKey("LEVEL", "Warn"); err != nil { - fatal("Setting console log-level failed: %v", err) + 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() } - if !setting.InstallLock { - log.Error("Is '%s' really the right config path?\n", setting.CustomConf) - return fmt.Errorf("gitea is not initialized") - } - setting.LoadSettings() // cannot access session settings otherwise + setupConsoleLogger(util.Iif(quite, log.WARN, log.INFO), log.CanColorStderr, os.Stderr) - verbose := ctx.Bool("verbose") - if verbose && ctx.Bool("quiet") { - return fmt.Errorf("--quiet and --verbose cannot both be set") - } + setting.DisableLoggerInit() + setting.LoadSettings() // cannot access session settings otherwise stdCtx, cancel := installSignals() defer cancel() @@ -226,44 +141,32 @@ func runDump(ctx *cli.Context) error { return err } - if err := storage.Init(); err != nil { + if err = storage.Init(); err != nil { return err } - if file == nil { - file, err = os.Create(fileName) - if err != nil { - fatal("Unable to open %s: %v", fileName, err) - } - } - defer file.Close() - - absFileName, err := filepath.Abs(fileName) - if err != nil { - return err - } - - var iface any - if fileName == "-" { - iface, err = archiver.ByExtension(fmt.Sprintf(".%s", outType)) - } else { - iface, err = archiver.ByExtension(fileName) - } + archiverGeneric, err := archiver.ByExtension("." + outType) if err != nil { fatal("Unable to get archiver for extension: %v", err) } - w, _ := iface.(archiver.Writer) - if err := w.Create(file); err != nil { + archiverWriter := archiverGeneric.(archiver.Writer) + if err := archiverWriter.Create(outFile); err != nil { fatal("Creating archiver.Writer failed: %v", err) } - defer w.Close() + defer archiverWriter.Close() + + dumper := &dump.Dumper{ + Writer: archiverWriter, + Verbose: verbose, + } + dumper.GlobalExcludeAbsPath(outFileName) if ctx.IsSet("skip-repository") && ctx.Bool("skip-repository") { log.Info("Skip dumping local repositories") } else { log.Info("Dumping local repositories... %s", setting.RepoRootPath) - if err := addRecursiveExclude(w, "repos", setting.RepoRootPath, []string{absFileName}, verbose); err != nil { + if err := dumper.AddRecursiveExclude("repos", setting.RepoRootPath, nil); err != nil { fatal("Failed to include repositories: %v", err) } @@ -276,8 +179,7 @@ func runDump(ctx *cli.Context) error { if err != nil { return err } - - return addReader(w, object, info, path.Join("data", "lfs", objPath), verbose) + return dumper.AddReader(object, info, path.Join("data", "lfs", objPath)) }); err != nil { fatal("Failed to dump LFS objects: %v", err) } @@ -310,15 +212,13 @@ func runDump(ctx *cli.Context) error { fatal("Failed to dump database: %v", err) } - if err := addFile(w, "gitea-db.sql", dbDump.Name(), verbose); err != nil { + if err = dumper.AddFile("gitea-db.sql", dbDump.Name()); err != nil { fatal("Failed to include gitea-db.sql: %v", err) } - if len(setting.CustomConf) > 0 { - log.Info("Adding custom configuration file from %s", setting.CustomConf) - if err := addFile(w, "app.ini", setting.CustomConf, verbose); err != nil { - fatal("Failed to include specified app.ini: %v", err) - } + log.Info("Adding custom configuration file from %s", setting.CustomConf) + if err = dumper.AddFile("app.ini", setting.CustomConf); err != nil { + fatal("Failed to include specified app.ini: %v", err) } if ctx.IsSet("skip-custom-dir") && ctx.Bool("skip-custom-dir") { @@ -326,8 +226,8 @@ func runDump(ctx *cli.Context) error { } else { customDir, err := os.Stat(setting.CustomPath) if err == nil && customDir.IsDir() { - if is, _ := isSubdir(setting.AppDataPath, setting.CustomPath); !is { - if err := addRecursiveExclude(w, "custom", setting.CustomPath, []string{absFileName}, verbose); err != nil { + 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 { @@ -364,8 +264,7 @@ func runDump(ctx *cli.Context) error { excludes = append(excludes, setting.Attachment.Storage.Path) excludes = append(excludes, setting.Packages.Storage.Path) excludes = append(excludes, setting.Log.RootPath) - excludes = append(excludes, absFileName) - if err := addRecursiveExclude(w, "data", setting.AppDataPath, excludes, verbose); err != nil { + if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil { fatal("Failed to include data directory: %v", err) } } @@ -377,8 +276,7 @@ func runDump(ctx *cli.Context) error { if err != nil { return err } - - return addReader(w, object, info, path.Join("data", "attachments", objPath), verbose) + return dumper.AddReader(object, info, path.Join("data", "attachments", objPath)) }); err != nil { fatal("Failed to dump attachments: %v", err) } @@ -392,8 +290,7 @@ func runDump(ctx *cli.Context) error { if err != nil { return err } - - return addReader(w, object, info, path.Join("data", "packages", objPath), verbose) + return dumper.AddReader(object, info, path.Join("data", "packages", objPath)) }); err != nil { fatal("Failed to dump packages: %v", err) } @@ -409,80 +306,23 @@ func runDump(ctx *cli.Context) error { log.Error("Unable to check if %s exists. Error: %v", setting.Log.RootPath, err) } if isExist { - if err := addRecursiveExclude(w, "log", setting.Log.RootPath, []string{absFileName}, verbose); err != nil { + if err := dumper.AddRecursiveExclude("log", setting.Log.RootPath, nil); err != nil { fatal("Failed to include log: %v", err) } } } - if fileName != "-" { - if err = w.Close(); err != nil { - _ = util.Remove(fileName) - fatal("Failed to save %s: %v", fileName, err) + if outFileName == "-" { + log.Info("Finish dumping to stdout") + } else { + if err = archiverWriter.Close(); err != nil { + _ = os.Remove(outFileName) + fatal("Failed to save %q: %v", outFileName, err) } - - if err := os.Chmod(fileName, 0o600); err != nil { + if err = os.Chmod(outFileName, 0o600); err != nil { log.Info("Can't change file access permissions mask to 0600: %v", err) } - } - - if fileName != "-" { - log.Info("Finish dumping in file %s", fileName) - } else { - log.Info("Finish dumping to stdout") - } - - return nil -} - -// addRecursiveExclude zips absPath to specified insidePath inside writer excluding excludeAbsPath -func addRecursiveExclude(w archiver.Writer, insidePath, absPath string, excludeAbsPath []string, verbose bool) error { - absPath, err := filepath.Abs(absPath) - if err != nil { - return err - } - dir, err := os.Open(absPath) - if err != nil { - return err - } - defer dir.Close() - - files, err := dir.Readdir(0) - if err != nil { - return err - } - for _, file := range files { - currentAbsPath := path.Join(absPath, file.Name()) - currentInsidePath := path.Join(insidePath, file.Name()) - if file.IsDir() { - if !util.SliceContainsString(excludeAbsPath, currentAbsPath) { - if err := addFile(w, currentInsidePath, currentAbsPath, false); err != nil { - return err - } - if err = addRecursiveExclude(w, currentInsidePath, currentAbsPath, excludeAbsPath, verbose); err != nil { - return err - } - } - } else { - // only copy regular files and symlink regular files, skip non-regular files like socket/pipe/... - shouldAdd := file.Mode().IsRegular() - if !shouldAdd && file.Mode()&os.ModeSymlink == os.ModeSymlink { - target, err := filepath.EvalSymlinks(currentAbsPath) - if err != nil { - return err - } - targetStat, err := os.Stat(target) - if err != nil { - return err - } - shouldAdd = targetStat.Mode().IsRegular() - } - if shouldAdd { - if err = addFile(w, currentInsidePath, currentAbsPath, verbose); err != nil { - return err - } - } - } + log.Info("Finish dumping in file %s", outFileName) } return nil } diff --git a/cmd/generate.go b/cmd/generate.go index 5922617217a..90b32ecaf0e 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -18,7 +18,7 @@ var ( // CmdGenerate represents the available generate sub-command. CmdGenerate = &cli.Command{ Name: "generate", - Usage: "Command line interface for running generators", + Usage: "Generate Gitea's secrets/keys/tokens", Subcommands: []*cli.Command{ subcmdSecret, }, @@ -70,7 +70,7 @@ func runGenerateInternalToken(c *cli.Context) error { } func runGenerateLfsJwtSecret(c *cli.Context) error { - _, jwtSecretBase64, err := generate.NewJwtSecretBase64() + _, jwtSecretBase64, err := generate.NewJwtSecretWithBase64() if err != nil { return err } diff --git a/cmd/hook.go b/cmd/hook.go index f38fd8831b8..c04591d79ec 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -31,8 +31,8 @@ var ( // CmdHook represents the available hooks sub-command. CmdHook = &cli.Command{ Name: "hook", - Usage: "Delegate commands to corresponding Git hooks", - Description: "This should only be called by Git", + Usage: "(internal) Should only be called by Git", + Description: "Delegate commands to corresponding Git hooks", Before: PrepareConsoleLoggerLevel(log.FATAL), Subcommands: []*cli.Command{ subcmdHookPreReceive, @@ -376,7 +376,9 @@ Gitea or set your environment appropriately.`, "") oldCommitIDs[count] = string(fields[0]) newCommitIDs[count] = string(fields[1]) refFullNames[count] = git.RefName(fields[2]) - if refFullNames[count] == git.BranchPrefix+"master" && newCommitIDs[count] != git.EmptySHA && count == total { + + commitID, _ := git.NewIDFromString(newCommitIDs[count]) + if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total { masterPushed = true } count++ @@ -446,21 +448,24 @@ Gitea or set your environment appropriately.`, "") func hookPrintResults(results []private.HookPostReceiveBranchResult) { for _, res := range results { - if !res.Message { - continue - } + hookPrintResult(res.Message, res.Create, res.Branch, res.URL) + } +} - fmt.Fprintln(os.Stderr, "") - if res.Create { - fmt.Fprintf(os.Stderr, "Create a new pull request for '%s':\n", res.Branch) - fmt.Fprintf(os.Stderr, " %s\n", res.URL) - } else { - fmt.Fprint(os.Stderr, "Visit the existing pull request:\n") - fmt.Fprintf(os.Stderr, " %s\n", res.URL) - } - fmt.Fprintln(os.Stderr, "") - os.Stderr.Sync() +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 { @@ -669,7 +674,8 @@ Gitea or set your environment appropriately.`, "") if err != nil { return err } - if rs.OldOID != git.EmptySHA { + commitID, _ := git.NewIDFromString(rs.OldOID) + if !commitID.IsZero() { err = writeDataPktLine(ctx, os.Stdout, []byte("option old-oid "+rs.OldOID)) if err != nil { return err @@ -688,6 +694,12 @@ Gitea or set your environment appropriately.`, "") } err = writeFlushPktLine(ctx, os.Stdout) + if err == nil { + for _, res := range resp.Results { + hookPrintResult(res.ShouldShowMessage, res.IsCreatePR, res.HeadBranch, res.URL) + } + } + return err } diff --git a/cmd/keys.go b/cmd/keys.go index b8467825293..7fdbe16119f 100644 --- a/cmd/keys.go +++ b/cmd/keys.go @@ -16,10 +16,11 @@ import ( // CmdKeys represents the available keys sub-command var CmdKeys = &cli.Command{ - Name: "keys", - Usage: "This command queries the Gitea database to get the authorized command for a given ssh key fingerprint", - Before: PrepareConsoleLoggerLevel(log.FATAL), - Action: runKeys, + Name: "keys", + Usage: "(internal) Should only be called by SSH server", + 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", @@ -70,13 +71,13 @@ func runKeys(c *cli.Context) error { ctx, cancel := installSignals() defer cancel() - setup(ctx, false) + 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.App.Writer, strings.TrimSpace(authorizedString)) + _, _ = fmt.Fprintln(c.App.Writer, strings.TrimSpace(authorizedString.Text)) return nil } diff --git a/cmd/mailer.go b/cmd/mailer.go index 646330e85aa..0c5f2c8c8d4 100644 --- a/cmd/mailer.go +++ b/cmd/mailer.go @@ -45,6 +45,6 @@ func runSendMail(c *cli.Context) error { if extra.HasError() { return handleCliResponseExtra(extra) } - _, _ = fmt.Printf("Sent %s email(s) to all users\n", respText) + _, _ = fmt.Printf("Sent %s email(s) to all users\n", respText.Text) return nil } diff --git a/cmd/main.go b/cmd/main.go index feda41e68b2..02dd660e9ee 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,12 +10,12 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "github.com/urfave/cli/v2" ) // cmdHelp is our own help subcommand with more information +// Keep in mind that the "./gitea help"(subcommand) is different from "./gitea --help"(flag), the flag doesn't parse the config or output "DEFAULT CONFIGURATION:" information func cmdHelp() *cli.Command { c := &cli.Command{ Name: "help", @@ -47,16 +47,10 @@ DEFAULT CONFIGURATION: return c } -var helpFlag = cli.HelpFlag - -func init() { - // cli.HelpFlag = nil TODO: after https://github.com/urfave/cli/issues/1794 we can use this -} - func appGlobalFlags() []cli.Flag { return []cli.Flag{ // make the builtin flags at the top - helpFlag, + cli.HelpFlag, // shared configuration flags, they are for global and for each sub-command at the same time // eg: such command is valid: "./gitea --config /tmp/app.ini web --config /tmp/app.ini", while it's discouraged indeed @@ -121,20 +115,22 @@ func prepareWorkPathAndCustomConf(action cli.ActionFunc) func(ctx *cli.Context) func NewMainApp(version, versionExtra string) *cli.App { app := cli.NewApp() app.Name = "Gitea" + app.HelpName = "gitea" app.Usage = "A painless self-hosted Git service" - app.Description = `By default, Gitea will start serving using the web-server with no argument, which can alternatively be run by running the subcommand "web".` + 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 = version + versionExtra app.EnableBashCompletion = true // these sub-commands need to use config file subCmdWithConfig := []*cli.Command{ + cmdHelp(), // the "help" sub-command was used to show the more information for "work path" and "custom config" CmdWeb, CmdServ, CmdHook, + CmdKeys, CmdDump, CmdAdmin, CmdMigrate, - CmdKeys, CmdDoctor, CmdManager, CmdEmbedded, @@ -142,13 +138,8 @@ func NewMainApp(version, versionExtra string) *cli.App { CmdDumpRepository, CmdRestoreRepository, CmdActions, - cmdHelp(), // the "help" sub-command was used to show the more information for "work path" and "custom config" } - cmdConvert := util.ToPointer(*cmdDoctorConvert) - cmdConvert.Hidden = true // still support the legacy "./gitea doctor" by the hidden sub-command, remove it in next release - subCmdWithConfig = append(subCmdWithConfig, cmdConvert) - // these sub-commands do not need the config file, and they do not depend on any path or environment variable. subCmdStandalone := []*cli.Command{ CmdCert, diff --git a/cmd/main_test.go b/cmd/main_test.go index 0e32dce5648..a916c61f853 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -20,9 +20,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: "..", - }) + unittest.MainTest(m) } func makePathOutput(workPath, customPath, customConf string) string { diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go index acc3ba16bab..aa49445a89e 100644 --- a/cmd/migrate_storage.go +++ b/cmd/migrate_storage.go @@ -110,6 +110,9 @@ func migrateLFS(ctx context.Context, dstStorage storage.ObjectStorage) error { 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 }) @@ -117,6 +120,9 @@ func migrateAvatars(ctx context.Context, dstStorage storage.ObjectStorage) error 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 }) diff --git a/cmd/migrate_storage_test.go b/cmd/migrate_storage_test.go index 644e0dc18bc..5d8c8679930 100644 --- a/cmd/migrate_storage_test.go +++ b/cmd/migrate_storage_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -30,7 +31,7 @@ func TestMigratePackages(t *testing.T) { assert.NoError(t, err) defer buf.Close() - v, f, err := packages_service.CreatePackageAndAddFile(&packages_service.PackageCreationInfo{ + v, f, err := packages_service.CreatePackageAndAddFile(db.DefaultContext, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: creator, PackageType: packages.TypeGeneric, diff --git a/cmd/serv.go b/cmd/serv.go index 26fc91a3b7a..90190a19db6 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -42,7 +42,7 @@ const ( // CmdServ represents the available serv sub-command. var CmdServ = &cli.Command{ Name: "serv", - Usage: "This command should only be called by SSH shell", + Usage: "(internal) Should only be called by SSH shell", Description: "Serv provides access auth for repositories", Before: PrepareConsoleLoggerLevel(log.FATAL), Action: runServ, @@ -63,21 +63,10 @@ func setup(ctx context.Context, debug bool) { setupConsoleLogger(log.FATAL, false, os.Stderr) } setting.MustInstalled() - if debug { - setting.RunMode = "dev" - } - - // Check if setting.RepoRootPath exists. It could be the case that it doesn't exist, this can happen when - // `[repository]` `ROOT` is a relative path and $GITEA_WORK_DIR isn't passed to the SSH connection. if _, err := os.Stat(setting.RepoRootPath); err != nil { - if os.IsNotExist(err) { - _ = fail(ctx, "Incorrect configuration, no repository directory.", "Directory `[repository].ROOT` %q was not found, please check if $GITEA_WORK_DIR is passed to the SSH connection or make `[repository].ROOT` an absolute value.", setting.RepoRootPath) - } else { - _ = fail(ctx, "Incorrect configuration, repository directory is inaccessible", "Directory `[repository].ROOT` %q is inaccessible. err: %v", setting.RepoRootPath, err) - } + _ = fail(ctx, "Unable to access repository path", "Unable to access repository path %q, err: %v", setting.RepoRootPath, err) return } - if err := git.InitSimple(context.Background()); err != nil { _ = fail(ctx, "Failed to init git", "Failed to init git, err: %v", err) } @@ -216,16 +205,18 @@ func runServ(c *cli.Context) error { } } - // LowerCase and trim the repoPath as that's how they are stored. - repoPath = strings.ToLower(strings.TrimSpace(repoPath)) - rr := strings.SplitN(repoPath, "/", 2) if len(rr) != 2 { return fail(ctx, "Invalid repository path", "Invalid repository path: %v", repoPath) } - username := strings.ToLower(rr[0]) - reponame := strings.ToLower(strings.TrimSuffix(rr[1], ".git")) + username := rr[0] + reponame := strings.TrimSuffix(rr[1], ".git") + + // LowerCase and trim the repoPath as that's how they are stored. + // This should be done after splitting the repoPath into username and reponame + // so that username and reponame are not affected. + repoPath = strings.ToLower(strings.TrimSpace(repoPath)) if alphaDashDotPattern.MatchString(reponame) { return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reponame) diff --git a/cmd/web.go b/cmd/web.go index 01386251bec..ef8a7426c14 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -114,7 +114,7 @@ func showWebStartupMessage(msg string) { log.Info("* WorkPath: %s", setting.AppWorkPath) log.Info("* CustomPath: %s", setting.CustomPath) log.Info("* ConfigFile: %s", setting.CustomConf) - log.Info("%s", msg) + log.Info("%s", msg) // show startup message } func serveInstall(ctx *cli.Context) error { diff --git a/contrib/backport/backport.go b/contrib/backport/backport.go index 5cd0fe0f6e6..820c0702b74 100644 --- a/contrib/backport/backport.go +++ b/contrib/backport/backport.go @@ -17,7 +17,7 @@ import ( "strings" "syscall" - "github.com/google/go-github/v53/github" + "github.com/google/go-github/v57/github" "github.com/urfave/cli/v2" "gopkg.in/yaml.v3" ) diff --git a/contrib/environment-to-ini/environment-to-ini.go b/contrib/environment-to-ini/environment-to-ini.go index 7758045fd32..a7d7a6d293d 100644 --- a/contrib/environment-to-ini/environment-to-ini.go +++ b/contrib/environment-to-ini/environment-to-ini.go @@ -47,24 +47,28 @@ func main() { on the configuration cheat sheet.` app.Flags = []cli.Flag{ &cli.StringFlag{ - Name: "custom-path, C", - Value: setting.CustomPath, - Usage: "Custom path file path", + Name: "custom-path", + Aliases: []string{"C"}, + Value: setting.CustomPath, + Usage: "Custom path file path", }, &cli.StringFlag{ - Name: "config, c", - Value: setting.CustomConf, - Usage: "Custom configuration file path", + Name: "config", + Aliases: []string{"c"}, + Value: setting.CustomConf, + Usage: "Custom configuration file path", }, &cli.StringFlag{ - Name: "work-path, w", - Value: setting.AppWorkPath, - Usage: "Set the gitea working path", + Name: "work-path", + Aliases: []string{"w"}, + Value: setting.AppWorkPath, + Usage: "Set the gitea working path", }, &cli.StringFlag{ - Name: "out, o", - Value: "", - Usage: "Destination file to write to", + Name: "out", + Aliases: []string{"o"}, + Value: "", + Usage: "Destination file to write to", }, } app.Action = runEnvironmentToIni diff --git a/contrib/fixtures/fixture_generation.go b/contrib/fixtures/fixture_generation.go index 06c0354efa8..31797cc800f 100644 --- a/contrib/fixtures/fixture_generation.go +++ b/contrib/fixtures/fixture_generation.go @@ -5,6 +5,7 @@ package main import ( + "context" "fmt" "os" "path/filepath" @@ -18,7 +19,7 @@ import ( var ( generators = []struct { - gen func() (string, error) + gen func(ctx context.Context) (string, error) name string }{ { @@ -41,16 +42,17 @@ func main() { fmt.Printf("PrepareTestDatabase: %+v\n", err) os.Exit(1) } + ctx := context.Background() if len(os.Args) == 0 { for _, r := range os.Args { - if err := generate(r); err != nil { + if err := generate(ctx, r); err != nil { fmt.Printf("generate '%s': %+v\n", r, err) os.Exit(1) } } } else { for _, g := range generators { - if err := generate(g.name); err != nil { + if err := generate(ctx, g.name); err != nil { fmt.Printf("generate '%s': %+v\n", g.name, err) os.Exit(1) } @@ -58,10 +60,10 @@ func main() { } } -func generate(name string) error { +func generate(ctx context.Context, name string) error { for _, g := range generators { if g.name == name { - data, err := g.gen() + data, err := g.gen(ctx) if err != nil { return err } diff --git a/contrib/systemd/gitea.service b/contrib/systemd/gitea.service index c097fb0d176..c091722a743 100644 --- a/contrib/systemd/gitea.service +++ b/contrib/systemd/gitea.service @@ -1,6 +1,5 @@ [Unit] Description=Gitea (Git with a cup of tea) -After=syslog.target After=network.target ### # Don't forget to add the database service dependencies @@ -52,7 +51,7 @@ After=network.target # 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=notify +Type=simple User=git Group=git WorkingDirectory=/var/lib/gitea/ @@ -62,7 +61,6 @@ WorkingDirectory=/var/lib/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 -WatchdogSec=30s # 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 diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000000..35a38d768ca --- /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.ini" + translation: "/options/locale/locale_%locale%.ini" + type: "ini" + skip_untranslated_strings: true + export_only_approved: true + update_option: "update_as_unapproved" diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index cd6b2a914d2..918252044be 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -60,6 +60,7 @@ RUN_USER = ; git ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; The protocol the server listens on. One of 'http', 'https', 'http+unix', 'fcgi' or 'fcgi+unix'. Defaults to 'http' +;; Note: Value must be lowercase. ;PROTOCOL = http ;; ;; Expect PROXY protocol headers on connections @@ -233,7 +234,7 @@ RUN_USER = ; git ;MINIMUM_KEY_SIZE_CHECK = false ;; ;; Disable CDN even in "prod" mode -;OFFLINE_MODE = false +;OFFLINE_MODE = true ;; ;; TLS Settings: Either ACME or manual ;; (Other common TLS configuration are found before) @@ -350,6 +351,7 @@ NAME = gitea USER = root ;PASSWD = ;Use PASSWD = `your password` for quoting if you use special characters in the password. ;SSL_MODE = false ; either "false" (default), "true", or "skip-verify" +;CHARSET_COLLATION = ; Empty as default, Gitea will try to find a case-sensitive collation. Don't change it unless you clearly know what you need. ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; @@ -381,6 +383,7 @@ USER = root ;NAME = gitea ;USER = SA ;PASSWD = MwantsaSecurePassword1 +;CHARSET_COLLATION = ; Empty as default, Gitea will try to find a case-sensitive collation. Don't change it unless you clearly know what you need. ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; @@ -409,6 +412,10 @@ USER = root ;; ;; Whether execute database models migrations automatically ;AUTO_MIGRATION = true +;; +;; Threshold value (in seconds) beyond which query execution time is logged as a warning in the xorm logger +;; +;SLOW_QUERY_THRESHOLD = 5s ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -428,13 +435,13 @@ SECRET_KEY = ;SECRET_KEY_URI = file:/etc/gitea/secret_key ;; ;; Secret used to validate communication within Gitea binary. -INTERNAL_TOKEN= +INTERNAL_TOKEN = ;; ;; Alternative location to specify internal token, instead of this file; you cannot specify both this and INTERNAL_TOKEN, and must pick one ;INTERNAL_TOKEN_URI = file:/etc/gitea/internal_token ;; ;; How long to remember that a user is logged in before requiring relogin (in days) -;LOGIN_REMEMBER_DAYS = 7 +;LOGIN_REMEMBER_DAYS = 31 ;; ;; Name of the cookie used to store the current username. ;COOKIE_USERNAME = gitea_awesome @@ -491,6 +498,11 @@ INTERNAL_TOKEN= ;; Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. ;; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security. ;SUCCESSFUL_TOKENS_CACHE_SIZE = 20 +;; +;; Reject API tokens sent in URL query string (Accept Header-based API tokens only). This avoids security vulnerabilities +;; stemming from cached/logged plain-text API tokens. +;; In future releases, this will become the default behavior +;DISABLE_QUERY_AUTH_TOKEN = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -516,7 +528,7 @@ INTERNAL_TOKEN= ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; Enables OAuth2 provider -ENABLE = true +ENABLED = true ;; ;; Algorithm used to sign OAuth2 tokens. Valid values: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, EdDSA ;JWT_SIGNING_ALGORITHM = RS256 @@ -548,7 +560,8 @@ ENABLE = true ;; Pre-register OAuth2 applications for some universally useful services ;; * https://github.com/hickford/git-credential-oauth ;; * https://github.com/git-ecosystem/git-credential-manager -;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager +;; * https://gitea.com/gitea/tea +;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -943,6 +956,12 @@ LEVEL = Info ;GO_GET_CLONE_URL_PROTOCOL = https ;; ;; Close issues as long as a commit on any branch marks it as fixed +;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false +;; +;; Allow users to push local repositories to Gitea and have them automatically created for a user or an org +;ENABLE_PUSH_CREATE_USER = false +;ENABLE_PUSH_CREATE_ORG = false +;; ;; Comma separated list of globally disabled repo units. Allowed values: repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki, repo.projects, repo.packages, repo.actions. ;DISABLED_REPO_UNITS = ;; @@ -950,7 +969,7 @@ LEVEL = Info ;; Note: Code and Releases can currently not be deactivated. If you specify default repo units you should still list them for future compatibility. ;; External wiki and issue tracker can't be enabled by default as it requires additional settings. ;; Disabled repo units will not be added to new repositories regardless if it is in the default list. -;DEFAULT_REPO_UNITS = repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages +;DEFAULT_REPO_UNITS = repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages,repo.actions ;; ;; Comma separated list of default forked repo units. ;; The set of allowed values and rules are the same as DEFAULT_REPO_UNITS. @@ -1014,8 +1033,8 @@ LEVEL = Info ;; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. ;ALLOWED_TYPES = ;; -;; Max size of each file in megabytes. Defaults to 3MB -;FILE_MAX_SIZE = 3 +;; Max size of each file in megabytes. Defaults to 50MB +;FILE_MAX_SIZE = 50 ;; ;; Max number of files per upload. Defaults to 5 ;MAX_FILES = 5 @@ -1035,7 +1054,7 @@ LEVEL = Info ;; List of keywords used in Pull Request comments to automatically reopen a related issue ;REOPEN_KEYWORDS = reopen,reopens,reopened ;; -;; Set default merge style for repository creating, valid options: merge, rebase, rebase-merge, squash +;; Set default merge style for repository creating, valid options: merge, rebase, rebase-merge, squash, fast-forward-only ;DEFAULT_MERGE_STYLE = merge ;; ;; In the default merge message for squash commits include at most this many commits @@ -1058,6 +1077,9 @@ LEVEL = Info ;; ;; In addition to testing patches using the three-way merge method, re-test conflicting patches with git apply ;TEST_CONFLICTING_PATCHES_WITH_GIT_APPLY = false +;; +;; Retarget child pull requests to the parent pull request branch target on merge of parent pull request. It only works on merged PRs where the head and base branch target the same repo. +;RETARGET_CHILDREN_ON_MERGE = true ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1151,15 +1173,9 @@ LEVEL = Info ;; enable cors headers (disabled by default) ;ENABLED = false ;; -;; scheme of allowed requests -;SCHEME = http -;; -;; list of requesting domains that are allowed +;; list of requesting origins that are allowed, eg: "https://*.example.com" ;ALLOW_DOMAIN = * ;; -;; allow subdomains of headers listed above to request -;ALLOW_SUBDOMAIN = false -;; ;; list of methods allowed to request ;METHODS = GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS ;; @@ -1205,20 +1221,26 @@ LEVEL = Info ;; Max size of files to be displayed (default is 8MiB) ;MAX_DISPLAY_FILE_SIZE = 8388608 ;; +;; Detect ambiguous unicode characters in file contents and show warnings on the UI +;AMBIGUOUS_UNICODE_DETECTION = true +;; ;; Whether the email of the user should be shown in the Explore Users page ;SHOW_USER_EMAIL = true ;; ;; Set the default theme for the Gitea install -;DEFAULT_THEME = auto +;DEFAULT_THEME = gitea-auto ;; ;; All available themes. Allow users select personalized themes regardless of the value of `DEFAULT_THEME`. -;THEMES = auto,gitea,arc-green +;THEMES = gitea-auto,gitea-light,gitea-dark ;; ;; All available reactions users can choose on issues/prs and comments. ;; Values can be emoji alias (:smile:) or a unicode emoji. ;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png ;REACTIONS = +1, -1, laugh, hooray, confused, heart, rocket, eyes ;; +;; Change the number of users that are displayed in reactions tooltip (triggered by mouse hover). +;REACTION_MAX_USER_NUM = 10 +;; ;; Additional Emojis not defined in the utf8 standard ;; By default we support gitea (:gitea:), to add more copy them to public/assets/img/emoji/emoji_name.png and add it to this config. ;; Dont mistake it for Reactions. @@ -1233,6 +1255,14 @@ LEVEL = Info ;; Whether to only show relevant repos on the explore page when no keyword is specified and default sorting is used. ;; A repo is considered irrelevant if it's a fork or if it has no metadata (no description, no icon, no topic). ;ONLY_SHOW_RELEVANT_REPOS = false +;; +;; Change the sort type of the explore pages. +;; Default is "recentupdate", but you also have "alphabetically", "reverselastlogin", "newest", "oldest". +;EXPLORE_PAGING_DEFAULT_SORT = recentupdate +;; +;; The tense all timestamps should be rendered in. Possible values are `absolute` time (i.e. 1970-01-01, 11:59) and `mixed`. +;; `mixed` means most timestamps are rendered in relative time (i.e. 2 days ago). +;PREFERRED_TIMESTAMP_TENSE = mixed ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1420,7 +1450,7 @@ LEVEL = Info ;DATADIR = queues/ ; Relative paths will be made absolute against `%(APP_DATA_PATH)s`. ;; ;; Default queue length before a channel queue will block -;LENGTH = 100 +;LENGTH = 100000 ;; ;; Batch size to send for batched queues ;BATCH_LENGTH = 20 @@ -1450,6 +1480,16 @@ LEVEL = Info ;; ;; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled ;DEFAULT_EMAIL_NOTIFICATIONS = enabled +;; Disabled features for users, could be "deletion", "manage_ssh_keys","manage_gpg_keys" more features can be disabled in future +;; - deletion: a user cannot delete their own account +;; - manage_ssh_keys: a user cannot configure ssh keys +;; - manage_gpg_keys: a user cannot configure gpg keys +;USER_DISABLED_FEATURES = +;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior. +;; - deletion: a user cannot delete their own account +;; - manage_ssh_keys: a user cannot configure ssh keys +;; - manage_gpg_keys: a user cannot configure gpg keys +;;EXTERNAL_USER_DISABLE_FEATURES = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1514,6 +1554,10 @@ LEVEL = Info ;; userid = use the userid / sub attribute ;; nickname = use the nickname attribute ;; email = use the username part of the email attribute +;; Note: `nickname` and `email` options will normalize input strings using the following criteria: +;; - diacritics are removed +;; - the characters in the set `['´\x60]` are removed +;; - the characters in the set `[\s~+]` are replaced with `-` ;USERNAME = nickname ;; ;; Update avatar if available from oauth2 provider. @@ -1688,9 +1732,6 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; -;; if the cache enabled -;ENABLED = true -;; ;; Either "memory", "redis", "memcache", or "twoqueue". default is "memory" ;ADAPTER = memory ;; @@ -1715,8 +1756,6 @@ LEVEL = Info ;[cache.last_commit] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; if the cache enabled -;ENABLED = true ;; ;; Time to keep items in cache if not used, default is 8760 hours. ;; Setting it to -1 disables caching @@ -1812,8 +1851,8 @@ LEVEL = Info ;; Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. ;ALLOWED_TYPES = .csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip ;; -;; Max size of each file. Defaults to 4MB -;MAX_SIZE = 4 +;; Max size of each file. Defaults to 2048MB +;MAX_SIZE = 2048 ;; ;; Max number of files per upload. Defaults to 5 ;MAX_FILES = 5 @@ -2276,6 +2315,8 @@ LEVEL = Info ;SHOW_FOOTER_VERSION = true ;; Show template execution time in the footer ;SHOW_FOOTER_TEMPLATE_LOAD_TIME = true +;; Show the "powered by" text in the footer +;SHOW_FOOTER_POWERED_BY = true ;; Generate sitemap. Defaults to `true`. ;ENABLE_SITEMAP = true ;; Enable/Disable RSS/Atom feed @@ -2566,8 +2607,16 @@ LEVEL = Info ;; ;; Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance. ;DEFAULT_ACTIONS_URL = github -;; Default artifact retention time in days, default is 90 days +;; Default artifact retention time in days. Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step. ;ARTIFACT_RETENTION_DAYS = 90 +;; Timeout to stop the task which have running status, but haven't been updated for a long time +;ZOMBIE_TASK_TIMEOUT = 10m +;; Timeout to stop the tasks which have running status and continuous updates, but don't end for a long time +;ENDLESS_TASK_TIMEOUT = 3h +;; Timeout to cancel the jobs which have waiting status, but haven't been picked by a runner for a long time +;ABANDONED_JOB_TIMEOUT = 24h +;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow +;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docker/root/usr/bin/entrypoint b/docker/root/usr/bin/entrypoint index 0acfec4dbe4..d9dbb3ebe0b 100755 --- a/docker/root/usr/bin/entrypoint +++ b/docker/root/usr/bin/entrypoint @@ -7,7 +7,7 @@ if [ ! -x /bin/sh ]; then fi if [ "${USER}" != "git" ]; then - # rename user + # Rename user sed -i -e "s/^git\:/${USER}\:/g" /etc/passwd fi @@ -19,13 +19,13 @@ if [ -z "${USER_UID}" ]; then USER_UID="`id -u ${USER}`" fi -## Change GID for USER? +# 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? +# 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 diff --git a/docs/content/administration.fr-fr.md b/docs/content/administration.fr-fr.md deleted file mode 100644 index ed11881b770..00000000000 --- a/docs/content/administration.fr-fr.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -date: "2017-08-23T09:00:00+02:00" -title: "Avancé" -slug: "administration" -sidebar_position: 30 -toc: false -draft: false -menu: - sidebar: - name: "Avancé" - sidebar_position: 20 - identifier: "administration" ---- diff --git a/docs/content/administration.zh-tw.md b/docs/content/administration.zh-tw.md deleted file mode 100644 index 455d6a363fb..00000000000 --- a/docs/content/administration.zh-tw.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -date: "2016-12-01T16:00:00+02:00" -title: "運維" -slug: "administration" -sidebar_position: 30 -toc: false -draft: false -menu: - sidebar: - name: "運維" - sidebar_position: 20 - identifier: "administration" ---- diff --git a/docs/content/administration/_index.zh-tw.md b/docs/content/administration/_index.zh-tw.md deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/content/administration/adding-legal-pages.en-us.md b/docs/content/administration/adding-legal-pages.en-us.md index c6f68edcd0e..1ff0c0132d2 100644 --- a/docs/content/administration/adding-legal-pages.en-us.md +++ b/docs/content/administration/adding-legal-pages.en-us.md @@ -19,10 +19,10 @@ Some jurisdictions (such as EU), requires certain legal pages (e.g. Privacy Poli ## Getting Pages -Gitea source code ships with sample pages, available in `contrib/legal` directory. Copy them to `custom/public/`. For example, to add Privacy Policy: +Gitea source code ships with sample pages, available in `contrib/legal` directory. Copy them to `custom/public/assets/`. For example, to add Privacy Policy: ``` -wget -O /path/to/custom/public/privacy.html https://raw.githubusercontent.com/go-gitea/gitea/main/contrib/legal/privacy.html.sample +wget -O /path/to/custom/public/assets/privacy.html https://raw.githubusercontent.com/go-gitea/gitea/main/contrib/legal/privacy.html.sample ``` Now you need to edit the page to meet your requirements. In particular you must change the email addresses, web addresses and references to "Your Gitea Instance" to match your situation. diff --git a/docs/content/administration/adding-legal-pages.zh-cn.md b/docs/content/administration/adding-legal-pages.zh-cn.md index 5d582e871de..3e18c6e6b03 100644 --- a/docs/content/administration/adding-legal-pages.zh-cn.md +++ b/docs/content/administration/adding-legal-pages.zh-cn.md @@ -19,10 +19,10 @@ menu: ## 获取页面 -Gitea 源代码附带了示例页面,位于 `contrib/legal` 目录中。将它们复制到 `custom/public/` 目录下。例如,如果要添加隐私政策: +Gitea 源代码附带了示例页面,位于 `contrib/legal` 目录中。将它们复制到 `custom/public/assets/` 目录下。例如,如果要添加隐私政策: ``` -wget -O /path/to/custom/public/privacy.html https://raw.githubusercontent.com/go-gitea/gitea/main/contrib/legal/privacy.html.sample +wget -O /path/to/custom/public/assets/privacy.html https://raw.githubusercontent.com/go-gitea/gitea/main/contrib/legal/privacy.html.sample ``` 现在,你需要编辑该页面以满足你的需求。特别是,你必须更改电子邮件地址、网址以及与 "Your Gitea Instance" 相关的引用,以匹配你的情况。 diff --git a/docs/content/administration/backup-and-restore.en-us.md b/docs/content/administration/backup-and-restore.en-us.md index d46efecf990..451ef5c944c 100644 --- a/docs/content/administration/backup-and-restore.en-us.md +++ b/docs/content/administration/backup-and-restore.en-us.md @@ -92,7 +92,7 @@ cd gitea-dump-1610949662 mv app.ini /etc/gitea/conf/app.ini mv data/* /var/lib/gitea/data/ mv log/* /var/lib/gitea/log/ -mv repos/* /var/lib/gitea/gitea-repositories/ +mv repos/* /var/lib/gitea/data/gitea-repositories/ chown -R gitea:gitea /etc/gitea/conf/app.ini /var/lib/gitea # mysql @@ -111,6 +111,8 @@ With Gitea running, and from the directory Gitea's binary is located, execute: ` This ensures that application and configuration file paths in repository Git Hooks are consistent and applicable to the current installation. If these paths are not updated, repository `push` actions will fail. +If you still have issues, consider running `./gitea doctor check` to inspect possible errors (or run with `--fix`). + ### Using Docker (`restore`) There is also no support for a recovery command in a Docker-based gitea instance. The restore process contains the same steps as described in the previous section but with different paths. diff --git a/docs/content/administration/backup-and-restore.zh-cn.md b/docs/content/administration/backup-and-restore.zh-cn.md index 98d378d5dcb..db7eba84f7e 100644 --- a/docs/content/administration/backup-and-restore.zh-cn.md +++ b/docs/content/administration/backup-and-restore.zh-cn.md @@ -19,6 +19,12 @@ menu: Gitea 已经实现了 `dump` 命令可以用来备份所有需要的文件到一个zip压缩文件。该压缩文件可以被用来进行数据恢复。 +## 备份一致性 + +为了确保 Gitea 实例的一致性,在备份期间必须关闭它。 + +Gitea 包括数据库、文件和 Git 仓库,当它被使用时所有这些都会发生变化。例如,当迁移正在进行时,在数据库中创建一个事务,而 Git 仓库正在被复制。如果备份发生在迁移的中间,Git 仓库可能是不完整的,尽管数据库声称它是完整的,因为它是在之后被转储的。避免这种竞争条件的唯一方法是在备份期间停止 Gitea 实例。 + ## 备份命令 (`dump`) 先转到git用户的权限: `su git`. 再Gitea目录运行 `./gitea dump`。一般会显示类似如下的输出: @@ -34,15 +40,43 @@ Gitea 已经实现了 `dump` 命令可以用来备份所有需要的文件到一 最后生成的 `gitea-dump-1482906742.zip` 文件将会包含如下内容: -* `custom` - 所有保存在 `custom/` 目录下的配置和自定义的文件。 -* `data` - 数据目录下的所有内容不包含使用文件session的文件。该目录包含 `attachments`, `avatars`, `lfs`, `indexers`, 如果使用sqlite 还会包含 sqlite 数据库文件。 +* `app.ini` - 如果原先存储在默认的 custom/ 目录之外,则是配置文件的可选副本 +* `custom/` - 所有保存在 `custom/` 目录下的配置和自定义的文件。 +* `data/` - 数据目录(APP_DATA_PATH),如果使用文件会话,则不包括会话。该目录包括 `attachments`、`avatars`、`lfs`、`indexers`、如果使用 SQLite 则包括 SQLite 文件。 +* `repos/` - 仓库目录的完整副本。 * `gitea-db.sql` - 数据库dump出来的 SQL。 -* `gitea-repo.zip` - Git仓库压缩文件。 * `log/` - Logs文件,如果用作迁移不是必须的。 中间备份文件将会在临时目录进行创建,如果您要重新指定临时目录,可以用 `--tempdir` 参数,或者用 `TMPDIR` 环境变量。 -## Restore Command (`restore`) +## 备份数据库 + +`gitea dump` 创建的 SQL 转储使用 XORM,Gitea 管理员可能更喜欢使用本地的 MySQL 和 PostgreSQL 转储工具。使用 XORM 转储数据库时仍然存在一些问题,可能会导致在尝试恢复时出现问题。 + +```sh +# mysql +mysqldump -u$USER -p$PASS --database $DATABASE > gitea-db.sql +# postgres +pg_dump -U $USER $DATABASE > gitea-db.sql +``` + +### 使用Docker (`dump`) + +在使用 Docker 时,使用 `dump` 命令有一些注意事项。 + +必须以 `gitea/conf/app.ini` 中指定的 `RUN_USER = ` 执行该命令;并且,为了让备份文件夹的压缩过程能够顺利执行,`docker exec` 命令必须在 `--tempdir` 内部执行。 + +示例: + +```none +docker exec -u -it -w <--tempdir> $(docker ps -qf 'name=^$') bash -c '/usr/local/bin/gitea dump -c ' +``` + +\*注意:`--tempdir` 指的是 Gitea 使用的 Docker 环境的临时目录;如果您没有指定自定义的 `--tempdir`,那么 Gitea 将使用 `/tmp` 或 Docker 容器的 `TMPDIR` 环境变量。对于 `--tempdir`,请相应调整您的 `docker exec` 命令选项。 + +结果应该是一个文件,存储在指定的 `--tempdir` 中,类似于:`gitea-dump-1482906742.zip` + +## 恢复命令 (`restore`) 当前还没有恢复命令,恢复需要人工进行。主要是把文件和数据库进行恢复。 @@ -51,10 +85,10 @@ Gitea 已经实现了 `dump` 命令可以用来备份所有需要的文件到一 ```sh unzip gitea-dump-1610949662.zip cd gitea-dump-1610949662 -mv data/conf/app.ini /etc/gitea/conf/app.ini +mv app.ini /etc/gitea/conf/app.ini mv data/* /var/lib/gitea/data/ mv log/* /var/lib/gitea/log/ -mv repos/* /var/lib/gitea/repositories/ +mv repos/* /var/lib/gitea/gitea-repositories/ chown -R gitea:gitea /etc/gitea/conf/app.ini /var/lib/gitea # mysql @@ -66,3 +100,55 @@ psql -U $USER -d $DATABASE < gitea-db.sql service gitea restart ``` + +如果安装方式发生了变化(例如 二进制 -> Docker),或者 Gitea 安装到了与之前安装不同的目录,则需要重新生成仓库 Git 钩子。 + +在 Gitea 运行时,并从 Gitea 二进制文件所在的目录执行:`./gitea admin regenerate hooks` + +这样可以确保仓库 Git 钩子中的应用程序和配置文件路径与当前安装一致。如果这些路径没有更新,仓库的 `push` 操作将失败。 + +### 使用 Docker (`restore`) + +在基于 Docker 的 Gitea 实例中,也没有恢复命令的支持。恢复过程与前面描述的步骤相同,但路径不同。 + +示例: + +```sh +# 在容器中打开 bash 会话 +docker exec --user git -it 2a83b293548e bash +# 在容器内解压您的备份文件 +unzip gitea-dump-1610949662.zip +cd gitea-dump-1610949662 +# 恢复 Gitea 数据 +mv data/* /data/gitea +# 恢复仓库本身 +mv repos/* /data/git/gitea-repositories/ +# 调整文件权限 +chown -R git:git /data +# 重新生成 Git 钩子 +/usr/local/bin/gitea -c '/data/gitea/conf/app.ini' admin regenerate hooks +``` + +Gitea 容器中的默认用户是 `git`(1000:1000)。请用您的 Gitea 容器 ID 或名称替换 `2a83b293548e`。 + +### 使用 Docker-rootless (`restore`) + +在 Docker-rootless 容器中的恢复工作流程只是要使用的目录不同: + +```sh +# 在容器中打开 bash 会话 +docker exec --user git -it 2a83b293548e bash +# 在容器内解压您的备份文件 +unzip gitea-dump-1610949662.zip +cd gitea-dump-1610949662 +# 恢复 app.ini +mv data/conf/app.ini /etc/gitea/app.ini +# 恢复 Gitea 数据 +mv data/* /var/lib/gitea +# 恢复仓库本身 +mv repos/* /var/lib/gitea/git/gitea-repositories +# 调整文件权限 +chown -R git:git /etc/gitea/app.ini /var/lib/gitea +# 重新生成 Git 钩子 +/usr/local/bin/gitea -c '/etc/gitea/app.ini' admin regenerate hooks +``` diff --git a/docs/content/administration/backup-and-restore.zh-tw.md b/docs/content/administration/backup-and-restore.zh-tw.md deleted file mode 100644 index 4966ccdc509..00000000000 --- a/docs/content/administration/backup-and-restore.zh-tw.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -date: "2017-01-01T16:00:00+02:00" -title: "用法: 備份與還原" -slug: "backup-and-restore" -sidebar_position: 11 -toc: false -draft: false -aliases: - - /zh-tw/backup-and-restore -menu: - sidebar: - parent: "administration" - name: "備份與還原" - sidebar_position: 11 - identifier: "backup-and-restore" ---- - -# 備份與還原 - -Gitea 目前支援 `dump` 指令,用來將資料備份成 zip 檔案,後續會提供還原指令,讓你可以輕易的將備份資料及還原到另外一台機器。 - -## 備份指令 (`dump`) - -首先,切換到執行 Gitea 的使用者: `su git` (請修改成您指定的使用者),並在安裝目錄內執行 `./gitea dump` 指令,你可以看到 console 畫面如下: - -``` -2016/12/27 22:32:09 Creating tmp work dir: /tmp/gitea-dump-417443001 -2016/12/27 22:32:09 Dumping local repositories.../home/git/gitea-repositories -2016/12/27 22:32:22 Dumping database... -2016/12/27 22:32:22 Packing dump files... -2016/12/27 22:32:34 Removing tmp work dir: /tmp/gitea-dump-417443001 -2016/12/27 22:32:34 Finish dumping in file gitea-dump-1482906742.zip -``` - -備份出來的 `gitea-dump-1482906742.zip` 檔案,檔案內會包含底下內容: - -* `custom/conf/app.ini` - 伺服器設定檔。 -* `gitea-db.sql` - SQL 備份檔案。 -* `gitea-repo.zip` - 此 zip 檔案為全部的 repo 目錄。 - 請參考 Config -> repository -> `ROOT` 所設定的路徑。 -* `log/` - 全部 logs 檔案,如果你要 migrate 到其他伺服器,此目錄不用保留。 - -你可以透過設定 `--tempdir` 指令參數來指定備份檔案目錄,或者是設定 `TMPDIR` 環境變數來達到此功能。 - -## 還原指令 (`restore`) - -持續更新中: 此文件尚未完成. - -例: - -```sh -unzip gitea-dump-1610949662.zip -cd gitea-dump-1610949662 -mv data/conf/app.ini /etc/gitea/conf/app.ini -mv data/* /var/lib/gitea/data/ -mv log/* /var/lib/gitea/log/ -mv repos/* /var/lib/gitea/repositories/ -chown -R gitea:gitea /etc/gitea/conf/app.ini /var/lib/gitea - -# mysql -mysql --default-character-set=utf8mb4 -u$USER -p$PASS $DATABASE **: Global secret key. This key is VERY IMPORTANT, if you lost it, the data encrypted by it (like 2FA secret) can't be decrypted anymore. - `SECRET_KEY_URI`: **_empty_**: Instead of defining SECRET_KEY, this option can be used to use the key stored in a file (example value: `file:/etc/gitea/secret_key`). It shouldn't be lost like SECRET_KEY. -- `LOGIN_REMEMBER_DAYS`: **7**: Cookie lifetime, in days. -- `COOKIE_USERNAME`: **gitea\_awesome**: Name of the cookie used to store the current username. +- `LOGIN_REMEMBER_DAYS`: **31**: How long to remember that a user is logged in before requiring relogin (in days). - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**: Name of cookie used to store authentication information. - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**: Header name for reverse proxy @@ -568,6 +583,7 @@ And the following unique queues: - off - do not check password complexity - `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed. - `SUCCESSFUL_TOKENS_CACHE_SIZE`: **20**: Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security. +- `DISABLE_QUERY_AUTH_TOKEN`: **false**: Reject API tokens sent in URL query string (Accept Header-based API tokens only). This setting will default to `true` in Gitea 1.23 and be deprecated in Gitea 1.24. ## Camo (`camo`) @@ -578,7 +594,7 @@ And the following unique queues: ## OpenID (`openid`) -- `ENABLE_OPENID_SIGNIN`: **false**: Allow authentication in via OpenID. +- `ENABLE_OPENID_SIGNIN`: **true**: Allow authentication in via OpenID. - `ENABLE_OPENID_SIGNUP`: **! DISABLE\_REGISTRATION**: Allow registering via OpenID. - `WHITELISTED_URIS`: **_empty_**: If non-empty, list of POSIX regex patterns matching OpenID URI's to permit. @@ -591,9 +607,13 @@ And the following unique queues: - `OPENID_CONNECT_SCOPES`: **_empty_**: List of additional openid connect scopes. (`openid` is implicitly added) - `ENABLE_AUTO_REGISTRATION`: **false**: Automatically create user accounts for new oauth2 users. - `USERNAME`: **nickname**: The source of the username for new oauth2 accounts: - - userid - use the userid / sub attribute - - nickname - use the nickname attribute - - email - use the username part of the email attribute + - `userid` - use the userid / sub attribute + - `nickname` - use the nickname attribute + - `email` - use the username part of the email attribute + - Note: `nickname` and `email` options will normalize input strings using the following criteria: + - diacritics are removed + - the characters in the set `['´\x60]` are removed + - the characters in the set `[\s~+]` are replaced with `-` - `UPDATE_AVATAR`: **false**: Update avatar if available from oauth2 provider. Update will be performed on each login. - `ACCOUNT_LINKING`: **login**: How to handle if an account / email already exists: - disabled - show an error @@ -617,7 +637,7 @@ And the following unique queues: - `REQUIRE_SIGNIN_VIEW`: **false**: Enable this to force users to log in to view any page or to use API. - `ENABLE_NOTIFY_MAIL`: **false**: Enable this to send e-mail to watchers of a repository when something happens, like creating issues. Requires `Mailer` to be enabled. -- `ENABLE_BASIC_AUTHENTICATION`: **true**: Disable this to disallow authenticaton using HTTP +- `ENABLE_BASIC_AUTHENTICATION`: **true**: Disable this to disallow authentication using HTTP BASIC and the user's password. Please note if you disable this you will not be able to access the tokens API endpoints using a password. Further, this only disables BASIC authentication using the password - not tokens or OAuth Basic. @@ -757,7 +777,6 @@ and ## Cache (`cache`) -- `ENABLED`: **true**: Enable the cache. - `ADAPTER`: **memory**: Cache engine adapter, either `memory`, `redis`, `redis-cluster`, `twoqueue` or `memcache`. (`twoqueue` represents a size limited LRU cache.) - `INTERVAL`: **60**: Garbage Collection interval (sec), for memory and twoqueue cache only. - `HOST`: **_empty_**: Connection string for `redis`, `redis-cluster` and `memcache`. For `twoqueue` sets configuration for the queue. @@ -769,7 +788,6 @@ and ## Cache - LastCommitCache settings (`cache.last_commit`) -- `ENABLED`: **true**: Enable the cache. - `ITEM_TTL`: **8760h**: Time to keep items in cache if not used, Setting it to -1 disables caching. - `COMMITS_COUNT`: **1000**: Only enable the cache when repository's commits count great than. @@ -818,8 +836,8 @@ Default templates for project boards: ## Issue and pull request attachments (`attachment`) - `ENABLED`: **true**: Whether issue and pull request attachments are enabled. -- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. -- `MAX_SIZE`: **4**: Maximum size (MB). +- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types. +- `MAX_SIZE`: **2048**: Maximum size (MB). - `MAX_FILES`: **5**: Maximum number of attachments that can be uploaded at once. - `STORAGE_TYPE`: **local**: Storage type for attachments, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]` - `SERVE_DIRECT`: **false**: Allows the storage driver to redirect to authenticated URLs to serve files directly. Currently, only Minio/S3 is supported via signed URLs, local does nothing. @@ -1098,7 +1116,7 @@ This section only does "set" config, a removed config key from this section won' ## OAuth2 (`oauth2`) -- `ENABLE`: **true**: Enables OAuth2 provider. +- `ENABLED`: **true**: Enables OAuth2 provider. - `ACCESS_TOKEN_EXPIRATION_TIME`: **3600**: Lifetime of an OAuth2 access token in seconds - `REFRESH_TOKEN_EXPIRATION_TIME`: **730**: Lifetime of an OAuth2 refresh token in hours - `INVALIDATE_REFRESH_TOKENS`: **false**: Check if refresh token has already been used @@ -1107,7 +1125,7 @@ This section only does "set" config, a removed config key from this section won' - `JWT_SECRET_URI`: **_empty_**: Instead of defining JWT_SECRET in the configuration, this configuration option can be used to give Gitea a path to a file that contains the secret (example value: `file:/etc/gitea/oauth2_jwt_secret`) - `JWT_SIGNING_PRIVATE_KEY_FILE`: **jwt/private.pem**: Private key file path used to sign OAuth2 tokens. The path is relative to `APP_DATA_PATH`. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `RS256`, `RS384`, `RS512`, `ES256`, `ES384` or `ES512`. The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you. - `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider -- `DEFAULT_APPLICATIONS`: **git-credential-oauth, git-credential-manager**: Pre-register OAuth applications for some services on startup. See the [OAuth2 documentation](/development/oauth2-provider.md) for the list of available options. +- `DEFAULT_APPLICATIONS`: **git-credential-oauth, git-credential-manager, tea**: Pre-register OAuth applications for some services on startup. See the [OAuth2 documentation](/development/oauth2-provider.md) for the list of available options. ## i18n (`i18n`) @@ -1277,7 +1295,7 @@ Default storage configuration for attachments, lfs, avatars, repo-avatars, repo- - `MINIO_USE_SSL`: **false**: Minio enabled ssl only available when `STORAGE_TYPE` is `minio` - `MINIO_INSECURE_SKIP_VERIFY`: **false**: Minio skip SSL verification available when STORAGE_TYPE is `minio` -The recommanded storage configuration for minio like below: +The recommended storage configuration for minio like below: ```ini [storage] @@ -1388,24 +1406,29 @@ PROXY_HOSTS = *.github.com - `DEFAULT_ACTIONS_URL`: **github**: Default platform to get action plugins, `github` for `https://github.com`, `self` for the current Gitea instance. - `STORAGE_TYPE`: **local**: Storage type for actions logs, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]` - `MINIO_BASE_PATH`: **actions_log/**: Minio base path on the bucket only available when STORAGE_TYPE is `minio` -- `ARTIFACT_RETENTION_DAYS`: **90**: Number of days to keep artifacts. Set to 0 to disable artifact retention. Default is 90 days if not set. +- `ARTIFACT_RETENTION_DAYS`: **90**: Default number of days to keep artifacts. Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step. +- `ZOMBIE_TASK_TIMEOUT`: **10m**: Timeout to stop the task which have running status, but haven't been updated for a long time +- `ENDLESS_TASK_TIMEOUT`: **3h**: Timeout to stop the tasks which have running status and continuous updates, but don't end for a long time +- `ABANDONED_JOB_TIMEOUT`: **24h**: Timeout to cancel the jobs which have waiting status, but haven't been picked by a runner for a long time +- `SKIP_WORKFLOW_STRINGS`: **[skip ci],[ci skip],[no ci],[skip actions],[actions skip]**: Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow `DEFAULT_ACTIONS_URL` indicates where the Gitea Actions runners should find the actions with relative path. -For example, `uses: actions/checkout@v3` means `https://github.com/actions/checkout@v3` since the value of `DEFAULT_ACTIONS_URL` is `github`. -And it can be changed to `self` to make it `root_url_of_your_gitea/actions/checkout@v3`. +For example, `uses: actions/checkout@v4` means `https://github.com/actions/checkout@v4` since the value of `DEFAULT_ACTIONS_URL` is `github`. +And it can be changed to `self` to make it `root_url_of_your_gitea/actions/checkout@v4`. Please note that using `self` is not recommended for most cases, as it could make names globally ambiguous. Additionally, it requires you to mirror all the actions you need to your Gitea instance, which may not be worth it. Therefore, please use `self` only if you understand what you are doing. -In earlier versions (<= 1.19), `DEFAULT_ACTIONS_URL` cound be set to any custom URLs like `https://gitea.com` or `http://your-git-server,https://gitea.com`, and the default value was `https://gitea.com`. +In earlier versions (`<= 1.19`), `DEFAULT_ACTIONS_URL` could be set to any custom URLs like `https://gitea.com` or `http://your-git-server,https://gitea.com`, and the default value was `https://gitea.com`. However, later updates removed those options, and now the only options are `github` and `self`, with the default value being `github`. However, if you want to use actions from other git server, you can use a complete URL in `uses` field, it's supported by Gitea (but not GitHub). -Like `uses: https://gitea.com/actions/checkout@v3` or `uses: http://your-git-server/actions/checkout@v3`. +Like `uses: https://gitea.com/actions/checkout@v4` or `uses: http://your-git-server/actions/checkout@v4`. ## Other (`other`) - `SHOW_FOOTER_VERSION`: **true**: Show Gitea and Go version information in the footer. - `SHOW_FOOTER_TEMPLATE_LOAD_TIME`: **true**: Show time of template execution in the footer. +- `SHOW_FOOTER_POWERED_BY`: **true**: Show the "powered by" text in the footer. - `ENABLE_SITEMAP`: **true**: Generate sitemap. - `ENABLE_FEED`: **true**: Enable/Disable RSS/Atom feed. diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md index 2849752c0ab..759f39b5769 100644 --- a/docs/content/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/administration/config-cheat-sheet.zh-cn.md @@ -29,7 +29,7 @@ menu: [ini](https://github.com/go-ini/ini/#recursive-values) 这里的说明。 标注了 :exclamation: 的配置项表明除非你真的理解这个配置项的意义,否则最好使用默认值。 -在下面的默认值中,`$XYZ`代表环境变量`XYZ`的值(详见:`enviroment-to-ini`)。 _`XxYyZz`_是指默认配置的一部分列出的值。这些在 app.ini 文件中不起作用,仅在此处列出作为文档说明。 +在下面的默认值中,`$XYZ`代表环境变量`XYZ`的值(详见:`environment-to-ini`)。 _`XxYyZz`_是指默认配置的一部分列出的值。这些在 app.ini 文件中不起作用,仅在此处列出作为文档说明。 包含`#`或者`;`的变量必须使用引号(`` ` ``或者`""""`)包裹,否则会被解析为注释。 @@ -102,7 +102,7 @@ menu: - `ENABLE_PUSH_CREATE_USER`: **false**: 允许用户将本地存储库推送到Gitea,并为用户自动创建它们。 - `ENABLE_PUSH_CREATE_ORG`: **false**: 允许用户将本地存储库推送到Gitea,并为组织自动创建它们。 - `DISABLED_REPO_UNITS`: **_empty_**: 逗号分隔的全局禁用的仓库单元列表。允许的值是:: \[repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki, repo.projects, repo.packages, repo.actions\] -- `DEFAULT_REPO_UNITS`: **repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages**: 逗号分隔的默认新仓库单元列表。允许的值是:: \[repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki, repo.projects, repo.packages, repo.actions\]. 注意:目前无法停用代码和发布。如果您指定了默认的仓库单元,您仍应将它们列出以保持未来的兼容性。外部wiki和问题跟踪器不能默认启用,因为它需要额外的设置。禁用的仓库单元将不会添加到新的仓库中,无论它是否在默认列表中。 +- `DEFAULT_REPO_UNITS`: **repo.code,repo.releases,repo.issues,repo.pulls,repo.wiki,repo.projects,repo.packages,repo.actions**: 逗号分隔的默认新仓库单元列表。允许的值是:: \[repo.code, repo.releases, repo.issues, repo.pulls, repo.wiki, repo.projects, repo.packages, repo.actions\]. 注意:目前无法停用代码和发布。如果您指定了默认的仓库单元,您仍应将它们列出以保持未来的兼容性。外部wiki和问题跟踪器不能默认启用,因为它需要额外的设置。禁用的仓库单元将不会添加到新的仓库中,无论它是否在默认列表中。 - `DEFAULT_FORK_REPO_UNITS`: **repo.code,repo.pulls**: 逗号分隔的默认分叉仓库单元列表。允许的值和规则与`DEFAULT_REPO_UNITS`相同。 - `PREFIX_ARCHIVE_FILES`: **true**: 通过将存档文件放置在以仓库命名的目录中来添加前缀。 - `DISABLE_MIGRATIONS`: **false**: 禁用迁移功能。 @@ -125,7 +125,7 @@ menu: - `CLOSE_KEYWORDS`: **close**, **closes**, **closed**, **fix**, **fixes**, **fixed**, **resolve**, **resolves**, **resolved**: 在拉取请求评论中用于自动关闭相关问题的关键词列表。 - `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: 在拉取请求评论中用于自动重新打开相关问题的 关键词列表。 -- `DEFAULT_MERGE_STYLE`: **merge**: 设置创建仓库的默认合并方式,可选: `merge`, `rebase`, `rebase-merge`, `squash` +- `DEFAULT_MERGE_STYLE`: **merge**: 设置创建仓库的默认合并方式,可选: `merge`, `rebase`, `rebase-merge`, `squash`, `fast-forward-only` - `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: 在默认合并消息中,对于`squash`提交,最多包括此数量的提交。设置为 -1 以包括所有提交。 - `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: 在默认的合并消息中,对于`squash`提交,限制提交消息的大小。设置为 `-1`以取消限制。仅在`POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES`为`true`时使用。 - `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: 在默认合并消息中,对于`squash`提交,遍历所有提交以包括所有作者的`Co-authored-by`,否则仅使用限定列表中的作者。 @@ -145,7 +145,7 @@ menu: - `ENABLED`: **true**: 是否启用仓库文件上传。 - `TEMP_PATH`: **data/tmp/uploads**: 文件上传的临时保存路径(在Gitea重启的时候该目录会被清空)。 - `ALLOWED_TYPES`: **_empty_**: 以逗号分割的列表,代表支持上传的文件类型。(`.zip`), mime类型 (`text/plain`) or 通配符类型 (`image/*`, `audio/*`, `video/*`). 为空或者 `*/*`代表允许所有类型文件。 -- `FILE_MAX_SIZE`: **3**: 每个文件的最大大小(MB)。 +- `FILE_MAX_SIZE`: **50**: 每个文件的最大大小(MB)。 - `MAX_FILES`: **5**: 每次上传的最大文件数。 ### 仓库 - 版本发布 (`repository.release`) @@ -195,9 +195,7 @@ menu: ## 跨域 (`cors`) - `ENABLED`: **false**: 启用 CORS 头部(默认禁用) -- `SCHEME`: **http**: 允许请求的协议 - `ALLOW_DOMAIN`: **\***: 允许请求的域名列表 -- `ALLOW_SUBDOMAIN`: **false**: 允许上述列出的头部的子域名发出请求。 - `METHODS`: **GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS**: 允许发起的请求方式列表 - `MAX_AGE`: **10m**: 缓存响应的最大时间 - `ALLOW_CREDENTIALS`: **false**: 允许带有凭据的请求 @@ -214,9 +212,9 @@ menu: - `SITEMAP_PAGING_NUM`: **20**: 在单个子SiteMap中显示的项数。 - `GRAPH_MAX_COMMIT_NUM`: **100**: 提交图中显示的最大commit数量。 - `CODE_COMMENT_LINES`: **4**: 在代码评论中能够显示的最大代码行数。 -- `DEFAULT_THEME`: **auto**: \[auto, gitea, arc-green\]: 在Gitea安装时候设置的默认主题。 +- `DEFAULT_THEME`: **gitea-auto**: \[gitea-auto, gitea-light, gitea-dark\]: 在Gitea安装时候设置的默认主题。 - `SHOW_USER_EMAIL`: **true**: 用户的电子邮件是否应该显示在`Explore Users`页面中。 -- `THEMES`: **auto,gitea,arc-green**: 所有可用的主题。允许用户选择个性化的主题, +- `THEMES`: **gitea-auto,gitea-light,gitea-dark**: 所有可用的主题。允许用户选择个性化的主题, 而不受DEFAULT_THEME 值的影响。 - `MAX_DISPLAY_FILE_SIZE`: **8388608**: 能够显示文件的最大大小(默认为8MiB)。 - `REACTIONS`: 用户可以在问题(Issue)、Pull Request(PR)以及评论中选择的所有可选的反应。 @@ -335,7 +333,7 @@ menu: - `SSH_AUTHORIZED_PRINCIPALS_ALLOW`: **off** 或 **username, email**:\[off, username, email, anything\]:指定允许用户用作 principal 的值。当设置为 `anything` 时,对 principal 字符串不执行任何检查。当设置为 `off` 时,不允许设置授权的 principal。 - `SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE`: **false/true**:当 Gitea 不使用内置 SSH 服务器且 `SSH_AUTHORIZED_PRINCIPALS_ALLOW` 不为 `off` 时,默认情况下 Gitea 会创建一个 authorized_principals 文件。 - `SSH_AUTHORIZED_PRINCIPALS_BACKUP`: **false/true**:在重写所有密钥时启用 SSH 授权 principal 备份,默认值为 true(如果 `SSH_AUTHORIZED_PRINCIPALS_ALLOW` 不为 `off`)。 -- `SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE`: **{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}**:设置用于传递授权密钥的命令模板。可能的密钥是:AppPath、AppWorkPath、CustomConf、CustomPath、Key,其中 Key 是 `models/asymkey.PublicKey`,其他是 shellquoted 字符串。 +- `SSH_AUTHORIZED_KEYS_COMMAND_TEMPLATE`: **`{{.AppPath}} --config={{.CustomConf}} serv key-{{.Key.ID}}`**:设置用于传递授权密钥的命令模板。可能的密钥是:AppPath、AppWorkPath、CustomConf、CustomPath、Key,其中 Key 是 `models/asymkey.PublicKey`,其他是 shellquoted 字符串。 - `SSH_SERVER_CIPHERS`: **chacha20-poly1305@openssh.com, aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, aes256-gcm@openssh.com**:对于内置的 SSH 服务器,选择支持的 SSH 连接的加密方法,对于系统 SSH,此设置无效。 - `SSH_SERVER_KEY_EXCHANGES`: **curve25519-sha256, ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521, diffie-hellman-group14-sha256, diffie-hellman-group14-sha1**:对于内置 SSH 服务器,选择支持的 SSH 连接的密钥交换算法,对于系统 SSH,此设置无效。 - `SSH_SERVER_MACS`: **hmac-sha2-256-etm@openssh.com, hmac-sha2-256, hmac-sha1**:对于内置 SSH 服务器,选择支持的 SSH 连接的 MAC 算法,对于系统 SSH,此设置无效。 @@ -346,7 +344,7 @@ menu: - `SSH_PER_WRITE_TIMEOUT`: **30s**:对 SSH 连接的任何写入设置超时。(将其设置为 -1 可以禁用所有超时。) - `SSH_PER_WRITE_PER_KB_TIMEOUT`: **10s**:对写入 SSH 连接的每 KB 设置超时。 - `MINIMUM_KEY_SIZE_CHECK`: **true**:指示是否检查最小密钥大小与相应类型。 -- `OFFLINE_MODE`: **false**:禁用 CDN 用于静态文件和 Gravatar 用于个人资料图片。 +- `OFFLINE_MODE`: **true**:禁用 CDN 用于静态文件和 Gravatar 用于个人资料图片。 - `CERT_FILE`: **https/cert.pem**:用于 HTTPS 的证书文件路径。在链接时,服务器证书必须首先出现,然后是中间 CA 证书(如果有)。如果 `ENABLE_ACME=true`,则此设置会被忽略。路径相对于 `CUSTOM_PATH`。 - `KEY_FILE`: **https/key.pem**:用于 HTTPS 的密钥文件路径。如果 `ENABLE_ACME=true`,则此设置会被忽略。路径相对于 `CUSTOM_PATH`。 - `STATIC_ROOT_PATH`: **_`StaticRootPath`_**:模板和静态文件路径的上一级。 @@ -472,7 +470,7 @@ menu: - `TYPE`:**level**:通用队列类型,当前支持:`level`(在内部使用 LevelDB)、`channel`、`redis`、`dummy`。无效的类型将视为 `level`。 - `DATADIR`:**queues/common**:用于存储 level 队列的基本 DataDir。单独的队列的 `DATADIR` 可以在 `queue.name` 部分进行设置。相对路径将根据 `%(APP_DATA_PATH)s` 变为绝对路径。 -- `LENGTH`:**100**:通道队列阻塞之前的最大队列大小 +- `LENGTH`:**100000**:通道队列阻塞之前的最大队列大小 - `BATCH_LENGTH`:**20**:在传递给处理程序之前批处理数据 - `CONN_STR`:**redis://127.0.0.1:6379/0**:redis 队列类型的连接字符串。对于 `redis-cluster`,使用 `redis+cluster://127.0.0.1:6379/0`。可以使用查询参数来设置选项。类似地,LevelDB 选项也可以使用:**leveldb://relative/path?option=value** 或 **leveldb:///absolute/path?option=value** 进行设置,并将覆盖 `DATADIR`。 - `QUEUE_NAME`:**_queue**:默认的 redis 和磁盘队列名称的后缀。单独的队列将默认为 **`name`**`QUEUE_NAME`,但可以在特定的 `queue.name` 部分中进行覆盖。 @@ -499,14 +497,17 @@ Gitea 创建以下非唯一队列: - `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**:用户电子邮件通知的默认配置(用户可配置)。选项:enabled、onmention、disabled - `DISABLE_REGULAR_ORG_CREATION`: **false**:禁止普通(非管理员)用户创建组织。 +- `USER_DISABLED_FEATURES`:**_empty_** 禁用的用户特性,当前允许为空或者 `deletion`,`manage_ssh_keys`, `manage_gpg_keys` 未来可以增加更多设置。 + - `deletion`: 用户不能通过界面或者API删除他自己。 + - `manage_ssh_keys`: 用户不能通过界面或者API配置SSH Keys。 + - `manage_gpg_keys`: 用户不能配置 GPG 密钥。 ## 安全性 (`security`) - `INSTALL_LOCK`: **false**:控制是否能够访问安装向导页面,设置为 `true` 则禁止访问安装向导页面。 - `SECRET_KEY`: **\<每次安装时随机生成\>**:全局服务器安全密钥。这个密钥非常重要,如果丢失将无法解密加密的数据(例如 2FA)。 - `SECRET_KEY_URI`: **_empty_**:与定义 `SECRET_KEY` 不同,此选项可用于使用存储在文件中的密钥(示例值:`file:/etc/gitea/secret_key`)。它不应该像 `SECRET_KEY` 一样容易丢失。 -- `LOGIN_REMEMBER_DAYS`: **7**:Cookie 保存时间,单位为天。 -- `COOKIE_USERNAME`: **gitea\_awesome**:保存用户名的 Cookie 名称。 +- `LOGIN_REMEMBER_DAYS`: **31**:在要求重新登录之前,记住用户的登录状态多长时间(以天为单位)。 - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**:保存自动登录信息的 Cookie 名称。 - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**:反向代理认证的 HTTP 头部名称,用于提供用户信息。 - `REVERSE_PROXY_AUTHENTICATION_EMAIL`: **X-WEBAUTH-EMAIL**:反向代理认证的 HTTP 头部名称,用于提供邮箱信息。 @@ -561,7 +562,7 @@ Gitea 创建以下非唯一队列: ## OpenID (`openid`) -- `ENABLE_OPENID_SIGNIN`: **false**:允许通过OpenID进行身份验证。 +- `ENABLE_OPENID_SIGNIN`: **true**:允许通过OpenID进行身份验证。 - `ENABLE_OPENID_SIGNUP`: **! DISABLE\_REGISTRATION**:允许通过OpenID进行注册。 - `WHITELISTED_URIS`: **_empty_**:如果非空,是一组匹配OpenID URI的POSIX正则表达式模式,用于允许访问。 - `BLACKLISTED_URIS`: **_empty_**:如果非空,是一组匹配OpenID URI的POSIX正则表达式模式,用于阻止访问。 @@ -722,7 +723,6 @@ Gitea 创建以下非唯一队列: ## 缓存 (`cache`) -- `ENABLED`: **true**: 是否启用缓存。 - `ADAPTER`: **memory**: 缓存引擎,可以为 `memory`, `redis`, `redis-cluster`, `twoqueue` 和 `memcache`. (`twoqueue` 代表缓冲区固定的LRU缓存) - `INTERVAL`: **60**: 垃圾回收间隔(秒),只对`memory`和`towqueue`有效。 - `HOST`: **_empty_**: 缓存配置。`redis`, `redis-cluster`,`memcache`配置连接字符串;`twoqueue` 设置队列参数 @@ -734,7 +734,6 @@ Gitea 创建以下非唯一队列: ### 缓存 - 最后提交缓存设置 (`cache.last_commit`) -- `ENABLED`: **true**:是否启用缓存。 - `ITEM_TTL`: **8760h**:如果未使用,保持缓存中的项目的时间,将其设置为 -1 会禁用缓存。 - `COMMITS_COUNT`: **1000**:仅在存储库的提交计数大于时启用缓存。 @@ -783,8 +782,8 @@ Gitea 创建以下非唯一队列: ## 工单和合并请求的附件 (`attachment`) - `ENABLED`: **true**: 是否允许用户上传附件。 -- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`)、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。 -- `MAX_SIZE`: **4**: 附件的最大限制(MB)。 +- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`)、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。 +- `MAX_SIZE`: **2048**: 附件的最大限制(MB)。 - `MAX_FILES`: **5**: 一次最多上传的附件数量。 - `STORAGE_TYPE`: **local**: 附件的存储类型,`local` 表示本地磁盘,`minio` 表示兼容 S3 的对象存储服务,如果未设置将使用默认值 `local` 或其他在 `[storage.xxx]` 中定义的名称。 - `SERVE_DIRECT`: **false**: 允许存储驱动器重定向到经过身份验证的 URL 以直接提供文件。目前,只支持 Minio/S3 通过签名 URL 提供支持,local 不会执行任何操作。 @@ -992,7 +991,7 @@ Gitea 创建以下非唯一队列: - `LAST_UPDATED_MORE_THAN_AGO`: **72h**: 只会尝试回收超过此时间(默认3天)没有尝试过回收的 LFSMetaObject。 - `NUMBER_TO_CHECK_PER_REPO`: **100**: 每个仓库要检查的过期 LFSMetaObject 的最小数量。设置为 `0` 以始终检查所有。 -# Git (`git`) +## Git (`git`) - `PATH`: **""**: Git可执行文件的路径。如果为空,Gitea将在PATH环境中搜索。 - `HOME_PATH`: **%(APP_DATA_PATH)s/home**: Git的HOME目录。 @@ -1040,14 +1039,15 @@ Gitea 创建以下非唯一队列: ## API (`api`) -- `ENABLE_SWAGGER`: **true**: 是否启用swagger路由 (`/api/swagger`, `/api/v1/swagger`, …)。 -- `MAX_RESPONSE_ITEMS`: **50**: 单个页面的最大 Feed. -- `ENABLE_OPENID_SIGNIN`: **false**: 允许使用OpenID登录,当设置为`true`时可以通过 `/user/login` 页面进行OpenID登录。 -- `DISABLE_REGISTRATION`: **false**: 关闭用户注册。 +- `ENABLE_SWAGGER`: **true**: 启用API文档接口 (`/api/swagger`, `/api/v1/swagger`, …). True or false。 +- `MAX_RESPONSE_ITEMS`: **50**: API分页的最大单页项目数。 +- `DEFAULT_PAGING_NUM`: **30**: API分页的默认分页数。 +- `DEFAULT_GIT_TREES_PER_PAGE`: **1000**: Git trees API的默认单页项目数。 +- `DEFAULT_MAX_BLOB_SIZE`: **10485760** (10MiB): blobs API的默认最大文件大小。 ## OAuth2 (`oauth2`) -- `ENABLE`: **true**:启用OAuth2提供者。 +- `ENABLED`: **true**:启用OAuth2提供者。 - `ACCESS_TOKEN_EXPIRATION_TIME`:**3600**:OAuth2访问令牌的生命周期,以秒为单位。 - `REFRESH_TOKEN_EXPIRATION_TIME`:**730**:OAuth2刷新令牌的生命周期,以小时为单位。 - `INVALIDATE_REFRESH_TOKENS`:**false**:检查刷新令牌是否已被使用。 @@ -1056,7 +1056,7 @@ Gitea 创建以下非唯一队列: - `JWT_SECRET_URI`:**_empty_**:可以使用此配置选项,而不是在配置中定义`JWT_SECRET`,以向Gitea提供包含密钥的文件的路径(示例值:`file:/etc/gitea/oauth2_jwt_secret`)。 - `JWT_SIGNING_PRIVATE_KEY_FILE`:**jwt/private.pem**:用于签署OAuth2令牌的私钥文件路径。路径相对于`APP_DATA_PATH`。仅当`JWT_SIGNING_ALGORITHM`设置为`RS256`,`RS384`,`RS512`,`ES256`,`ES384`或`ES512`时才需要此设置。文件必须包含PKCS8格式的RSA或ECDSA私钥。如果不存在密钥,则将为您创建一个4096位密钥。 - `MAX_TOKEN_LENGTH`:**32767**:从OAuth2提供者接受的令牌/cookie的最大长度。 -- `DEFAULT_APPLICATIONS`:**git-credential-oauth,git-credential-manager**:在启动时预注册用于某些服务的OAuth应用程序。有关可用选项列表,请参阅[OAuth2文档](/development/oauth2-provider.md)。 +- `DEFAULT_APPLICATIONS`:**git-credential-oauth,git-credential-manager, tea**:在启动时预注册用于某些服务的OAuth应用程序。有关可用选项列表,请参阅[OAuth2文档](/development/oauth2-provider.md)。 ## i18n (`i18n`) @@ -1337,21 +1337,22 @@ PROXY_HOSTS = *.github.com - `MINIO_BASE_PATH`: **actions_log/**:Minio存储桶上的基本路径,仅在`STORAGE_TYPE`为`minio`时可用。 `DEFAULT_ACTIONS_URL` 指示 Gitea 操作运行程序应该在哪里找到带有相对路径的操作。 -例如,`uses: actions/checkout@v3` 表示 `https://github.com/actions/checkout@v3`,因为 `DEFAULT_ACTIONS_URL` 的值为 `github`。 -它可以更改为 `self`,以使其成为 `root_url_of_your_gitea/actions/checkout@v3`。 +例如,`uses: actions/checkout@v4` 表示 `https://github.com/actions/checkout@v4`,因为 `DEFAULT_ACTIONS_URL` 的值为 `github`。 +它可以更改为 `self`,以使其成为 `root_url_of_your_gitea/actions/checkout@v4`。 请注意,对于大多数情况,不建议使用 `self`,因为它可能使名称在全局范围内产生歧义。 此外,它要求您将所有所需的操作镜像到您的 Gitea 实例,这可能不值得。 因此,请仅在您了解自己在做什么的情况下使用 `self`。 -在早期版本(<= 1.19)中,`DEFAULT_ACTIONS_URL` 可以设置为任何自定义 URL,例如 `https://gitea.com` 或 `http://your-git-server,https://gitea.com`,默认值为 `https://gitea.com`。 +在早期版本(`<= 1.19`)中,`DEFAULT_ACTIONS_URL` 可以设置为任何自定义 URL,例如 `https://gitea.com` 或 `http://your-git-server,https://gitea.com`,默认值为 `https://gitea.com`。 然而,后来的更新删除了这些选项,现在唯一的选项是 `github` 和 `self`,默认值为 `github`。 但是,如果您想要使用其他 Git 服务器中的操作,您可以在 `uses` 字段中使用完整的 URL,Gitea 支持此功能(GitHub 不支持)。 -例如 `uses: https://gitea.com/actions/checkout@v3` 或 `uses: http://your-git-server/actions/checkout@v3`。 +例如 `uses: https://gitea.com/actions/checkout@v4` 或 `uses: http://your-git-server/actions/checkout@v4`。 ## 其他 (`other`) - `SHOW_FOOTER_VERSION`: **true**: 在页面底部显示Gitea的版本。 - `SHOW_FOOTER_TEMPLATE_LOAD_TIME`: **true**: 在页脚显示模板执行的时间。 +- `SHOW_FOOTER_POWERED_BY`: **true**: 在页脚显示“由...提供动力”的文本。 - `ENABLE_SITEMAP`: **true**: 生成sitemap. - `ENABLE_FEED`: **true**: 是否启用RSS/Atom diff --git a/docs/content/administration/customizing-gitea.en-us.md b/docs/content/administration/customizing-gitea.en-us.md index 4c2d7ed0c41..7efddb2824e 100644 --- a/docs/content/administration/customizing-gitea.en-us.md +++ b/docs/content/administration/customizing-gitea.en-us.md @@ -284,7 +284,7 @@ syntax and shouldn't be touched without fully understanding these components. Google Analytics, Matomo (previously Piwik), and other analytics services can be added to Gitea. To add the tracking code, refer to the `Other additions to the page` section of this document, and add the JavaScript to the `$GITEA_CUSTOM/templates/custom/header.tmpl` file. -## Customizing gitignores, labels, licenses, locales, and readmes. +## Customizing gitignores, labels, licenses, locales, and readmes Place custom files in corresponding sub-folder under `custom/options`. @@ -370,7 +370,8 @@ A full list of supported emoji's is at [emoji list](https://gitea.com/gitea/gite ## Customizing the look of Gitea -The default built-in themes are `gitea` (light), `arc-green` (dark), and `auto` (chooses light or dark depending on operating system settings). +The built-in themes are `gitea-light`, `gitea-dark`, and `gitea-auto` (which automatically adapts to OS settings). + The default theme can be changed via `DEFAULT_THEME` in the [ui](administration/config-cheat-sheet.md#ui-ui) section of `app.ini`. Gitea also has support for user themes, which means every user can select which theme should be used. @@ -384,7 +385,7 @@ To make a custom theme available to all users: Community themes are listed in [gitea/awesome-gitea#themes](https://gitea.com/gitea/awesome-gitea#themes). -The `arc-green` theme source can be found [here](https://github.com/go-gitea/gitea/blob/main/web_src/css/themes/theme-arc-green.css). +The default theme sources can be found [here](https://github.com/go-gitea/gitea/blob/main/web_src/css/themes). If your custom theme is considered a dark theme, set the global css variable `--is-dark-theme` to `true`. This allows Gitea to adjust the Monaco code editor's theme accordingly. diff --git a/docs/content/administration/customizing-gitea.zh-cn.md b/docs/content/administration/customizing-gitea.zh-cn.md index 2babf03da79..f41533c69cb 100644 --- a/docs/content/administration/customizing-gitea.zh-cn.md +++ b/docs/content/administration/customizing-gitea.zh-cn.md @@ -42,11 +42,11 @@ Gitea 引用 `custom` 目录中的自定义配置文件来覆盖配置、模板 将自定义的公共文件(比如页面和图片)作为 webroot 放在 `custom/public/` 中来让 Gitea 提供这些自定义内容(符号链接将被追踪)。 -举例说明:`image.png` 存放在 `custom/public/`中,那么它可以通过链接 http://gitea.domain.tld/assets/image.png 访问。 +举例说明:`image.png` 存放在 `custom/public/assets/`中,那么它可以通过链接 http://gitea.domain.tld/assets/image.png 访问。 ## 修改默认头像 -替换以下目录中的 png 图片: `custom/public/img/avatar\_default.png` +替换以下目录中的 png 图片: `custom/public/assets/img/avatar\_default.png` ## 自定义 Gitea 页面 @@ -86,5 +86,6 @@ Gitea 引用 `custom` 目录中的自定义配置文件来覆盖配置、模板 ## 更改 Gitea 外观 -Gitea 目前由两种内置主题,分别为默认 `gitea` 主题和深色主题 `arc-green`,您可以通过修改 -`app.ini` [ui](administration/config-cheat-sheet.md#ui-ui) 部分的 `DEFAULT_THEME` 的值来变更至一个可用的 Gitea 外观。 +内置主题是“gitea-light”、“gitea-dark”和“gitea-auto”(自动适应操作系统设置)。 + +默认主题可以通过 `app.ini` 的 [ui](administration/config-cheat-sheet.md#ui-ui) 部分中的 `DEFAULT_THEME` 进行更改。 diff --git a/docs/content/administration/email-setup.en-us.md b/docs/content/administration/email-setup.en-us.md index 645a7a3f434..f9621e6075c 100644 --- a/docs/content/administration/email-setup.en-us.md +++ b/docs/content/administration/email-setup.en-us.md @@ -61,7 +61,7 @@ Please note: authentication is only supported when the SMTP server communication - STARTTLS (also known as Opportunistic TLS) via port 587. Initial connection is done over cleartext, but then be upgraded over TLS if the server supports it. - SMTPS connection (SMTP over TLS) via the default port 465. Connection to the server use TLS from the beginning. -- Forced SMTPS connection with `IS_TLS_ENABLED=true`. (These are both known as Implicit TLS.) +- Forced SMTPS connection with `PROTOCOL=smtps`. (These are both known as Implicit TLS.) This is due to protections imposed by the Go internal libraries against STRIPTLS attacks. Note that Implicit TLS is recommended by [RFC8314](https://tools.ietf.org/html/rfc8314#section-3) since 2018. diff --git a/docs/content/administration/email-setup.zh-cn.md b/docs/content/administration/email-setup.zh-cn.md index 0a7ac3378f9..2e670be85b3 100644 --- a/docs/content/administration/email-setup.zh-cn.md +++ b/docs/content/administration/email-setup.zh-cn.md @@ -55,13 +55,13 @@ PASSWD = `password` 要发送测试邮件以验证设置,请转到 Gitea > 站点管理 > 配置 > SMTP 邮件配置。 -有关所有选项的完整列表,请查看[配置速查表](doc/administration/config-cheat-sheet.zh-cn.md)。 +有关所有选项的完整列表,请查看[配置速查表](administration/config-cheat-sheet.md)。 请注意:只有在使用 TLS 或 `HOST=localhost` 加密 SMTP 服务器通信时才支持身份验证。TLS 加密可以通过以下方式进行: - 通过端口 587 的 STARTTLS(也称为 Opportunistic TLS)。初始连接是明文的,但如果服务器支持,则可以升级为 TLS。 - 通过默认端口 465 的 SMTPS 连接。连接到服务器从一开始就使用 TLS。 -- 使用 `IS_TLS_ENABLED=true` 进行强制的 SMTPS 连接。(这两种方式都被称为 Implicit TLS) +- 使用 `PROTOCOL=smtps` 进行强制的 SMTPS 连接。(这两种方式都被称为 Implicit TLS) 这是由于 Go 内部库对 STRIPTLS 攻击的保护机制。 请注意,自2018年起,[RFC8314](https://tools.ietf.org/html/rfc8314#section-3) 推荐使用 Implicit TLS。 diff --git a/docs/content/administration/environment-variables.en-us.md b/docs/content/administration/environment-variables.en-us.md index f910cf060e8..2c6fcbe681d 100644 --- a/docs/content/administration/environment-variables.en-us.md +++ b/docs/content/administration/environment-variables.en-us.md @@ -27,14 +27,15 @@ GITEA_CUSTOM=/home/gitea/custom ./gitea web ## From Go language -As Gitea is written in Go, it uses some Go variables, such as: +As Gitea is written in Go, it uses some variables that influence the behaviour of Go's runtime, such as: -- `GOOS` -- `GOARCH` -- [`GOPATH`](https://golang.org/cmd/go/#hdr-GOPATH_environment_variable) +- `GOMEMLIMIT` +- `GOGC` +- `GOMAXPROCS` +- `GODEBUG` For documentation about each of the variables available, refer to the -[official Go documentation](https://golang.org/cmd/go/#hdr-Environment_variables). +[official Go documentation on runtime environment variables](https://pkg.go.dev/runtime#hdr-Environment_Variables). ## Gitea files diff --git a/docs/content/administration/environment-variables.zh-cn.md b/docs/content/administration/environment-variables.zh-cn.md index 25e120becdc..d5be9e03c25 100644 --- a/docs/content/administration/environment-variables.zh-cn.md +++ b/docs/content/administration/environment-variables.zh-cn.md @@ -29,9 +29,9 @@ GITEA_CUSTOM=/home/gitea/custom ./gitea web * `GOOS` * `GOARCH` -* [`GOPATH`](https://golang.org/cmd/go/#hdr-GOPATH_environment_variable) +* [`GOPATH`](https://go.dev/cmd/go/#hdr-GOPATH_environment_variable) -您可以在[官方文档](https://golang.org/cmd/go/#hdr-Environment_variables)中查阅这些配置参数的详细信息。 +您可以在[官方文档](https://go.dev/cmd/go/#hdr-Environment_variables)中查阅这些配置参数的详细信息。 ## Gitea 的文件目录 diff --git a/docs/content/administration/external-renderers.zh-cn.md b/docs/content/administration/external-renderers.zh-cn.md index 0b53b452776..fdf7315d7b7 100644 --- a/docs/content/administration/external-renderers.zh-cn.md +++ b/docs/content/administration/external-renderers.zh-cn.md @@ -194,7 +194,7 @@ ALLOW_DATA_URI_IMAGES = true } ``` -将您的样式表添加到自定义目录中,例如 `custom/public/css/my-style-XXXXX.css`,并使用自定义的头文件 `custom/templates/custom/header.tmpl` 进行导入: +将您的样式表添加到自定义目录中,例如 `custom/public/assets/css/my-style-XXXXX.css`,并使用自定义的头文件 `custom/templates/custom/header.tmpl` 进行导入: ```html diff --git a/docs/content/administration/https-support.en-us.md b/docs/content/administration/https-support.en-us.md index 4e18722ddf1..981a29bd852 100644 --- a/docs/content/administration/https-support.en-us.md +++ b/docs/content/administration/https-support.en-us.md @@ -35,7 +35,7 @@ CERT_FILE = cert.pem KEY_FILE = key.pem ``` -Note that if your certificate is signed by a third party certificate authority (i.e. not self-signed), then cert.pem should contain the certificate chain. The server certificate must be the first entry in cert.pem, followed by the intermediaries in order (if any). The root certificate does not have to be included because the connecting client must already have it in order to estalbish the trust relationship. +Note that if your certificate is signed by a third party certificate authority (i.e. not self-signed), then cert.pem should contain the certificate chain. The server certificate must be the first entry in cert.pem, followed by the intermediaries in order (if any). The root certificate does not have to be included because the connecting client must already have it in order to establish the trust relationship. To learn more about the config values, please checkout the [Config Cheat Sheet](administration/config-cheat-sheet.md#server-server). For the `CERT_FILE` or `KEY_FILE` field, the file path is relative to the `GITEA_CUSTOM` environment variable when it is a relative path. It can be an absolute path as well. diff --git a/docs/content/administration/https-support.zh-cn.md b/docs/content/administration/https-support.zh-cn.md index add7906cc62..8beb06e80f8 100644 --- a/docs/content/administration/https-support.zh-cn.md +++ b/docs/content/administration/https-support.zh-cn.md @@ -33,7 +33,7 @@ CERT_FILE = cert.pem KEY_FILE = key.pem ``` -请注意,如果您的证书由第三方证书颁发机构签名(即不是自签名的),则 cert.pem 应包含证书链。服务器证书必须是 cert.pem 中的第一个条目,后跟中介(如果有)。不必包含根证书,因为连接客户端必须已经拥有根证书才能建立信任关系。要了解有关配置值的更多信息,请查看 [配置备忘单](../config-cheat-sheet#server-server)。 +请注意,如果您的证书由第三方证书颁发机构签名(即不是自签名的),则 cert.pem 应包含证书链。服务器证书必须是 cert.pem 中的第一个条目,后跟中介(如果有)。不必包含根证书,因为连接客户端必须已经拥有根证书才能建立信任关系。要了解有关配置值的更多信息,请查看 [配置备忘单](administration/config-cheat-sheet#server-server)。 对于“CERT_FILE”或“KEY_FILE”字段,当文件路径是相对路径时,文件路径相对于“GITEA_CUSTOM”环境变量。它也可以是绝对路径。 diff --git a/docs/content/administration/mail-templates.en-us.md b/docs/content/administration/mail-templates.en-us.md index a129453e1a8..8e4e416e8d7 100644 --- a/docs/content/administration/mail-templates.en-us.md +++ b/docs/content/administration/mail-templates.en-us.md @@ -85,7 +85,7 @@ Text and macros for the mail body Specifying a _subject_ section is optional (and therefore also the dash line separator). When used, the separator between _subject_ and _mail body_ templates requires at least three dashes; no other characters are allowed in the separator line. -_Subject_ and _mail body_ are parsed by [Golang's template engine](https://golang.org/pkg/text/template/) and +_Subject_ and _mail body_ are parsed by [Golang's template engine](https://go.dev/pkg/text/template/) and are provided with a _metadata context_ assembled for each notification. The context contains the following elements: | Name | Type | Available | Usage | @@ -110,7 +110,7 @@ All names are case sensitive. ### The _subject_ part of the template -The template engine used for the mail _subject_ is golang's [`text/template`](https://golang.org/pkg/text/template/). +The template engine used for the mail _subject_ is golang's [`text/template`](https://go.dev/pkg/text/template/). Please refer to the linked documentation for details about its syntax. The _subject_ is built using the following steps: @@ -138,7 +138,7 @@ the two templates, even if a valid subject template is present. ### The _mail body_ part of the template -The template engine used for the _mail body_ is golang's [`html/template`](https://golang.org/pkg/html/template/). +The template engine used for the _mail body_ is golang's [`html/template`](https://go.dev/pkg/html/template/). Please refer to the linked documentation for details about its syntax. The _mail body_ is parsed after the mail subject, so there is an additional _metadata_ field which is @@ -146,7 +146,7 @@ the actual rendered subject, after all considerations. The expected result is HTML (including structural elements like``, ``, etc.). Styling through ` \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-python.svg b/public/assets/img/svg/gitea-python.svg index 87585b2b694..68e19ef2be4 100644 --- a/public/assets/img/svg/gitea-python.svg +++ b/public/assets/img/svg/gitea-python.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-rubygems.svg b/public/assets/img/svg/gitea-rubygems.svg index db89cf9e05b..4e43bdf2f43 100644 --- a/public/assets/img/svg/gitea-rubygems.svg +++ b/public/assets/img/svg/gitea-rubygems.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-split.svg b/public/assets/img/svg/gitea-split.svg index e2c6f7db724..9ce3077a968 100644 --- a/public/assets/img/svg/gitea-split.svg +++ b/public/assets/img/svg/gitea-split.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-swift.svg b/public/assets/img/svg/gitea-swift.svg index 0e26bcd4529..41821001851 100644 --- a/public/assets/img/svg/gitea-swift.svg +++ b/public/assets/img/svg/gitea-swift.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-twitter.svg b/public/assets/img/svg/gitea-twitter.svg index 16598dd43be..5ed1e264cac 100644 --- a/public/assets/img/svg/gitea-twitter.svg +++ b/public/assets/img/svg/gitea-twitter.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-unlock.svg b/public/assets/img/svg/gitea-unlock.svg index b6339348507..595dec0e68e 100644 --- a/public/assets/img/svg/gitea-unlock.svg +++ b/public/assets/img/svg/gitea-unlock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-vscode.svg b/public/assets/img/svg/gitea-vscode.svg index 1d36330524e..453b9befcc9 100644 --- a/public/assets/img/svg/gitea-vscode.svg +++ b/public/assets/img/svg/gitea-vscode.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-vscodium.svg b/public/assets/img/svg/gitea-vscodium.svg new file mode 100644 index 00000000000..6aad3d3a648 --- /dev/null +++ b/public/assets/img/svg/gitea-vscodium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-whitespace.svg b/public/assets/img/svg/gitea-whitespace.svg index 35e19439f04..9d3b342b3d4 100644 --- a/public/assets/img/svg/gitea-whitespace.svg +++ b/public/assets/img/svg/gitea-whitespace.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-yandex.svg b/public/assets/img/svg/gitea-yandex.svg index 54ded7eb872..d24c0be537a 100644 --- a/public/assets/img/svg/gitea-yandex.svg +++ b/public/assets/img/svg/gitea-yandex.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/material-invert-colors.svg b/public/assets/img/svg/material-invert-colors.svg index 576ec72a785..feddf73ca4d 100644 --- a/public/assets/img/svg/material-invert-colors.svg +++ b/public/assets/img/svg/material-invert-colors.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/material-palette.svg b/public/assets/img/svg/material-palette.svg index f719dadc5d0..f98cef7bdcf 100644 --- a/public/assets/img/svg/material-palette.svg +++ b/public/assets/img/svg/material-palette.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-accessibility-inset.svg b/public/assets/img/svg/octicon-accessibility-inset.svg index 2ab660c5004..2a728a9cf74 100644 --- a/public/assets/img/svg/octicon-accessibility-inset.svg +++ b/public/assets/img/svg/octicon-accessibility-inset.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-accessibility.svg b/public/assets/img/svg/octicon-accessibility.svg index 6e26a53f3b8..fcd56827f5e 100644 --- a/public/assets/img/svg/octicon-accessibility.svg +++ b/public/assets/img/svg/octicon-accessibility.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-alert-fill.svg b/public/assets/img/svg/octicon-alert-fill.svg index 6173f0648c0..a2135affc17 100644 --- a/public/assets/img/svg/octicon-alert-fill.svg +++ b/public/assets/img/svg/octicon-alert-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-alert.svg b/public/assets/img/svg/octicon-alert.svg index 0d81ca2f4b7..1d97fbeb54f 100644 --- a/public/assets/img/svg/octicon-alert.svg +++ b/public/assets/img/svg/octicon-alert.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-archive.svg b/public/assets/img/svg/octicon-archive.svg index 6816984e69c..48ad67ec630 100644 --- a/public/assets/img/svg/octicon-archive.svg +++ b/public/assets/img/svg/octicon-archive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-both.svg b/public/assets/img/svg/octicon-arrow-both.svg index e5fa8275f80..aec2d6ac08d 100644 --- a/public/assets/img/svg/octicon-arrow-both.svg +++ b/public/assets/img/svg/octicon-arrow-both.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-down-left.svg b/public/assets/img/svg/octicon-arrow-down-left.svg index 6001d57767f..720f3208267 100644 --- a/public/assets/img/svg/octicon-arrow-down-left.svg +++ b/public/assets/img/svg/octicon-arrow-down-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-down.svg b/public/assets/img/svg/octicon-arrow-down.svg index 6fb58b26770..87b526311e7 100644 --- a/public/assets/img/svg/octicon-arrow-down.svg +++ b/public/assets/img/svg/octicon-arrow-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-left.svg b/public/assets/img/svg/octicon-arrow-left.svg index e347e060058..0e498725bc0 100644 --- a/public/assets/img/svg/octicon-arrow-left.svg +++ b/public/assets/img/svg/octicon-arrow-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-right.svg b/public/assets/img/svg/octicon-arrow-right.svg index 993df7ecf28..5298ea14219 100644 --- a/public/assets/img/svg/octicon-arrow-right.svg +++ b/public/assets/img/svg/octicon-arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-switch.svg b/public/assets/img/svg/octicon-arrow-switch.svg index daf1fc001d5..8d1bc1d7aca 100644 --- a/public/assets/img/svg/octicon-arrow-switch.svg +++ b/public/assets/img/svg/octicon-arrow-switch.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-up-right.svg b/public/assets/img/svg/octicon-arrow-up-right.svg index 4d760cec658..d3c0533c2f6 100644 --- a/public/assets/img/svg/octicon-arrow-up-right.svg +++ b/public/assets/img/svg/octicon-arrow-up-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-arrow-up.svg b/public/assets/img/svg/octicon-arrow-up.svg index fdd8fa6a2f3..b790d6e4e20 100644 --- a/public/assets/img/svg/octicon-arrow-up.svg +++ b/public/assets/img/svg/octicon-arrow-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-beaker.svg b/public/assets/img/svg/octicon-beaker.svg index 7c72b85494e..ce0ad4d8155 100644 --- a/public/assets/img/svg/octicon-beaker.svg +++ b/public/assets/img/svg/octicon-beaker.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bell-fill.svg b/public/assets/img/svg/octicon-bell-fill.svg index 96cdfb2842f..a385b9e5fda 100644 --- a/public/assets/img/svg/octicon-bell-fill.svg +++ b/public/assets/img/svg/octicon-bell-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bell-slash.svg b/public/assets/img/svg/octicon-bell-slash.svg index e1989c6b39f..344671d8a81 100644 --- a/public/assets/img/svg/octicon-bell-slash.svg +++ b/public/assets/img/svg/octicon-bell-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bell.svg b/public/assets/img/svg/octicon-bell.svg index c2f18ab371c..26903da2b0a 100644 --- a/public/assets/img/svg/octicon-bell.svg +++ b/public/assets/img/svg/octicon-bell.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-blocked.svg b/public/assets/img/svg/octicon-blocked.svg index be9ec7e6e9b..0d0a7c0b343 100644 --- a/public/assets/img/svg/octicon-blocked.svg +++ b/public/assets/img/svg/octicon-blocked.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bold.svg b/public/assets/img/svg/octicon-bold.svg index 396bf74ccfa..ea2545975e6 100644 --- a/public/assets/img/svg/octicon-bold.svg +++ b/public/assets/img/svg/octicon-bold.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-book.svg b/public/assets/img/svg/octicon-book.svg index ee48f48a84e..3b58ec1eaad 100644 --- a/public/assets/img/svg/octicon-book.svg +++ b/public/assets/img/svg/octicon-book.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bookmark-slash.svg b/public/assets/img/svg/octicon-bookmark-slash.svg index c3ebabe79d7..781ae92d224 100644 --- a/public/assets/img/svg/octicon-bookmark-slash.svg +++ b/public/assets/img/svg/octicon-bookmark-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-briefcase.svg b/public/assets/img/svg/octicon-briefcase.svg index 7d3559638c4..3293cc80ed0 100644 --- a/public/assets/img/svg/octicon-briefcase.svg +++ b/public/assets/img/svg/octicon-briefcase.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-broadcast.svg b/public/assets/img/svg/octicon-broadcast.svg index a89f1250b7b..e8c9f6d21bf 100644 --- a/public/assets/img/svg/octicon-broadcast.svg +++ b/public/assets/img/svg/octicon-broadcast.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-bug.svg b/public/assets/img/svg/octicon-bug.svg index f398ef82b83..20a09048d5f 100644 --- a/public/assets/img/svg/octicon-bug.svg +++ b/public/assets/img/svg/octicon-bug.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-cache.svg b/public/assets/img/svg/octicon-cache.svg index 1630aa2224b..5b8a7924bb0 100644 --- a/public/assets/img/svg/octicon-cache.svg +++ b/public/assets/img/svg/octicon-cache.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-calendar.svg b/public/assets/img/svg/octicon-calendar.svg index 3d43f26bc93..55fd2f49da1 100644 --- a/public/assets/img/svg/octicon-calendar.svg +++ b/public/assets/img/svg/octicon-calendar.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-check-circle-fill.svg b/public/assets/img/svg/octicon-check-circle-fill.svg index f3a9f6a15da..8840d55ce1d 100644 --- a/public/assets/img/svg/octicon-check-circle-fill.svg +++ b/public/assets/img/svg/octicon-check-circle-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-check-circle.svg b/public/assets/img/svg/octicon-check-circle.svg index 89ce3a750cb..63ff6d2b21b 100644 --- a/public/assets/img/svg/octicon-check-circle.svg +++ b/public/assets/img/svg/octicon-check-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-check.svg b/public/assets/img/svg/octicon-check.svg index e38a8f4103c..b76500b1312 100644 --- a/public/assets/img/svg/octicon-check.svg +++ b/public/assets/img/svg/octicon-check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-checkbox.svg b/public/assets/img/svg/octicon-checkbox.svg index 88ff9cf487f..b9711c5509a 100644 --- a/public/assets/img/svg/octicon-checkbox.svg +++ b/public/assets/img/svg/octicon-checkbox.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-checklist.svg b/public/assets/img/svg/octicon-checklist.svg index 7d4cd8566d4..172f13a4331 100644 --- a/public/assets/img/svg/octicon-checklist.svg +++ b/public/assets/img/svg/octicon-checklist.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-chevron-down.svg b/public/assets/img/svg/octicon-chevron-down.svg index 84e71ca4d8a..824e4764efb 100644 --- a/public/assets/img/svg/octicon-chevron-down.svg +++ b/public/assets/img/svg/octicon-chevron-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-chevron-left.svg b/public/assets/img/svg/octicon-chevron-left.svg index a56612a7eb3..ec2e25a9689 100644 --- a/public/assets/img/svg/octicon-chevron-left.svg +++ b/public/assets/img/svg/octicon-chevron-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-chevron-right.svg b/public/assets/img/svg/octicon-chevron-right.svg index e9d04c151a4..4a575153afa 100644 --- a/public/assets/img/svg/octicon-chevron-right.svg +++ b/public/assets/img/svg/octicon-chevron-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-chevron-up.svg b/public/assets/img/svg/octicon-chevron-up.svg index 958bd3ab980..4dac4b5049c 100644 --- a/public/assets/img/svg/octicon-chevron-up.svg +++ b/public/assets/img/svg/octicon-chevron-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-circle-slash.svg b/public/assets/img/svg/octicon-circle-slash.svg index 817158aa077..fbc3865094c 100644 --- a/public/assets/img/svg/octicon-circle-slash.svg +++ b/public/assets/img/svg/octicon-circle-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-circle.svg b/public/assets/img/svg/octicon-circle.svg index 6dc288a16ea..c2fa88b929e 100644 --- a/public/assets/img/svg/octicon-circle.svg +++ b/public/assets/img/svg/octicon-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-clock-fill.svg b/public/assets/img/svg/octicon-clock-fill.svg index 43e87de195f..423e5fd29d7 100644 --- a/public/assets/img/svg/octicon-clock-fill.svg +++ b/public/assets/img/svg/octicon-clock-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-clock.svg b/public/assets/img/svg/octicon-clock.svg index f1140b8efdc..186f6fbefc8 100644 --- a/public/assets/img/svg/octicon-clock.svg +++ b/public/assets/img/svg/octicon-clock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-cloud-offline.svg b/public/assets/img/svg/octicon-cloud-offline.svg index f0963b818d2..a4c3091638c 100644 --- a/public/assets/img/svg/octicon-cloud-offline.svg +++ b/public/assets/img/svg/octicon-cloud-offline.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-cloud.svg b/public/assets/img/svg/octicon-cloud.svg index 7ff6f5b3eab..38b6a76122d 100644 --- a/public/assets/img/svg/octicon-cloud.svg +++ b/public/assets/img/svg/octicon-cloud.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-code-of-conduct.svg b/public/assets/img/svg/octicon-code-of-conduct.svg index 2477aa7279a..20d4152643f 100644 --- a/public/assets/img/svg/octicon-code-of-conduct.svg +++ b/public/assets/img/svg/octicon-code-of-conduct.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-code-review.svg b/public/assets/img/svg/octicon-code-review.svg index ce9e16cb421..2ba5e125696 100644 --- a/public/assets/img/svg/octicon-code-review.svg +++ b/public/assets/img/svg/octicon-code-review.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-code-square.svg b/public/assets/img/svg/octicon-code-square.svg index a95ca37e162..8dadc44eee3 100644 --- a/public/assets/img/svg/octicon-code-square.svg +++ b/public/assets/img/svg/octicon-code-square.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-code.svg b/public/assets/img/svg/octicon-code.svg index 33c920829df..a18c3b6ef6c 100644 --- a/public/assets/img/svg/octicon-code.svg +++ b/public/assets/img/svg/octicon-code.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-codescan-checkmark.svg b/public/assets/img/svg/octicon-codescan-checkmark.svg index 9e150c49c32..e81d4e9e53e 100644 --- a/public/assets/img/svg/octicon-codescan-checkmark.svg +++ b/public/assets/img/svg/octicon-codescan-checkmark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-codescan.svg b/public/assets/img/svg/octicon-codescan.svg index 85cbc27392c..c03a0e582ed 100644 --- a/public/assets/img/svg/octicon-codescan.svg +++ b/public/assets/img/svg/octicon-codescan.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-codespaces.svg b/public/assets/img/svg/octicon-codespaces.svg index 701ceefd5fb..30a3890b568 100644 --- a/public/assets/img/svg/octicon-codespaces.svg +++ b/public/assets/img/svg/octicon-codespaces.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-columns.svg b/public/assets/img/svg/octicon-columns.svg index a6344001a16..a88b8071c2b 100644 --- a/public/assets/img/svg/octicon-columns.svg +++ b/public/assets/img/svg/octicon-columns.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-command-palette.svg b/public/assets/img/svg/octicon-command-palette.svg index e41255b40be..6c8528180ec 100644 --- a/public/assets/img/svg/octicon-command-palette.svg +++ b/public/assets/img/svg/octicon-command-palette.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-comment-discussion.svg b/public/assets/img/svg/octicon-comment-discussion.svg index 6be15c7bcfb..2a2728db042 100644 --- a/public/assets/img/svg/octicon-comment-discussion.svg +++ b/public/assets/img/svg/octicon-comment-discussion.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-comment.svg b/public/assets/img/svg/octicon-comment.svg index 63403858791..916d808d9ff 100644 --- a/public/assets/img/svg/octicon-comment.svg +++ b/public/assets/img/svg/octicon-comment.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-container.svg b/public/assets/img/svg/octicon-container.svg index 2e6056bf415..c8eeeb16ec0 100644 --- a/public/assets/img/svg/octicon-container.svg +++ b/public/assets/img/svg/octicon-container.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-copilot-error.svg b/public/assets/img/svg/octicon-copilot-error.svg index caaf0d5ec3d..d213328a454 100644 --- a/public/assets/img/svg/octicon-copilot-error.svg +++ b/public/assets/img/svg/octicon-copilot-error.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-copilot-warning.svg b/public/assets/img/svg/octicon-copilot-warning.svg index ce956452046..af5fa66827e 100644 --- a/public/assets/img/svg/octicon-copilot-warning.svg +++ b/public/assets/img/svg/octicon-copilot-warning.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-copilot.svg b/public/assets/img/svg/octicon-copilot.svg index b2a143c5c63..c23f45407f6 100644 --- a/public/assets/img/svg/octicon-copilot.svg +++ b/public/assets/img/svg/octicon-copilot.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-cpu.svg b/public/assets/img/svg/octicon-cpu.svg index 41b6e7cfaec..753b9b5799e 100644 --- a/public/assets/img/svg/octicon-cpu.svg +++ b/public/assets/img/svg/octicon-cpu.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-credit-card.svg b/public/assets/img/svg/octicon-credit-card.svg index 1ba32dee728..94c8f1581e7 100644 --- a/public/assets/img/svg/octicon-credit-card.svg +++ b/public/assets/img/svg/octicon-credit-card.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-cross-reference.svg b/public/assets/img/svg/octicon-cross-reference.svg index cca24cd13f6..80b122b3566 100644 --- a/public/assets/img/svg/octicon-cross-reference.svg +++ b/public/assets/img/svg/octicon-cross-reference.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-dash.svg b/public/assets/img/svg/octicon-dash.svg index f3665638a67..39ebd0d264e 100644 --- a/public/assets/img/svg/octicon-dash.svg +++ b/public/assets/img/svg/octicon-dash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-database.svg b/public/assets/img/svg/octicon-database.svg index 03b5de464f5..cbc9749286c 100644 --- a/public/assets/img/svg/octicon-database.svg +++ b/public/assets/img/svg/octicon-database.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-dependabot.svg b/public/assets/img/svg/octicon-dependabot.svg index cfda70fee28..250d10cc1b8 100644 --- a/public/assets/img/svg/octicon-dependabot.svg +++ b/public/assets/img/svg/octicon-dependabot.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-desktop-download.svg b/public/assets/img/svg/octicon-desktop-download.svg index 643829c9e94..4ddaa137dee 100644 --- a/public/assets/img/svg/octicon-desktop-download.svg +++ b/public/assets/img/svg/octicon-desktop-download.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-device-camera-video.svg b/public/assets/img/svg/octicon-device-camera-video.svg index ebbed57707f..4e7e1e7c4d6 100644 --- a/public/assets/img/svg/octicon-device-camera-video.svg +++ b/public/assets/img/svg/octicon-device-camera-video.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-device-camera.svg b/public/assets/img/svg/octicon-device-camera.svg index 7ad8d402ac8..bf4de4a59cb 100644 --- a/public/assets/img/svg/octicon-device-camera.svg +++ b/public/assets/img/svg/octicon-device-camera.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-device-desktop.svg b/public/assets/img/svg/octicon-device-desktop.svg index 2bb49e47784..4a6183688f6 100644 --- a/public/assets/img/svg/octicon-device-desktop.svg +++ b/public/assets/img/svg/octicon-device-desktop.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-device-mobile.svg b/public/assets/img/svg/octicon-device-mobile.svg index 2f0ca597529..cf247e215c1 100644 --- a/public/assets/img/svg/octicon-device-mobile.svg +++ b/public/assets/img/svg/octicon-device-mobile.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-devices.svg b/public/assets/img/svg/octicon-devices.svg index c351640dea7..84d2a88febe 100644 --- a/public/assets/img/svg/octicon-devices.svg +++ b/public/assets/img/svg/octicon-devices.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diamond.svg b/public/assets/img/svg/octicon-diamond.svg index cc30842ce93..82d0bcbcfe8 100644 --- a/public/assets/img/svg/octicon-diamond.svg +++ b/public/assets/img/svg/octicon-diamond.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff-added.svg b/public/assets/img/svg/octicon-diff-added.svg index 9ac76132be8..276d162129d 100644 --- a/public/assets/img/svg/octicon-diff-added.svg +++ b/public/assets/img/svg/octicon-diff-added.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff-ignored.svg b/public/assets/img/svg/octicon-diff-ignored.svg index 47d59486e5b..6949409bdfb 100644 --- a/public/assets/img/svg/octicon-diff-ignored.svg +++ b/public/assets/img/svg/octicon-diff-ignored.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff-modified.svg b/public/assets/img/svg/octicon-diff-modified.svg index 68969b68654..1c0d7296aa1 100644 --- a/public/assets/img/svg/octicon-diff-modified.svg +++ b/public/assets/img/svg/octicon-diff-modified.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff-removed.svg b/public/assets/img/svg/octicon-diff-removed.svg index 86bcb54838b..d366a107559 100644 --- a/public/assets/img/svg/octicon-diff-removed.svg +++ b/public/assets/img/svg/octicon-diff-removed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff-renamed.svg b/public/assets/img/svg/octicon-diff-renamed.svg index 96bec22a26b..f07999a6608 100644 --- a/public/assets/img/svg/octicon-diff-renamed.svg +++ b/public/assets/img/svg/octicon-diff-renamed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-diff.svg b/public/assets/img/svg/octicon-diff.svg index 30bc1e95df7..4714b0fdd43 100644 --- a/public/assets/img/svg/octicon-diff.svg +++ b/public/assets/img/svg/octicon-diff.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-discussion-closed.svg b/public/assets/img/svg/octicon-discussion-closed.svg index 08c17812d76..d97598d2aa2 100644 --- a/public/assets/img/svg/octicon-discussion-closed.svg +++ b/public/assets/img/svg/octicon-discussion-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-discussion-duplicate.svg b/public/assets/img/svg/octicon-discussion-duplicate.svg index 8c705dbd982..01fd6641e22 100644 --- a/public/assets/img/svg/octicon-discussion-duplicate.svg +++ b/public/assets/img/svg/octicon-discussion-duplicate.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-discussion-outdated.svg b/public/assets/img/svg/octicon-discussion-outdated.svg index 960920d6965..515e63afae7 100644 --- a/public/assets/img/svg/octicon-discussion-outdated.svg +++ b/public/assets/img/svg/octicon-discussion-outdated.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-dot-fill.svg b/public/assets/img/svg/octicon-dot-fill.svg index d9c61d9db55..17db30b0e00 100644 --- a/public/assets/img/svg/octicon-dot-fill.svg +++ b/public/assets/img/svg/octicon-dot-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-dot.svg b/public/assets/img/svg/octicon-dot.svg index 62dcc189fe9..fe03e3ded73 100644 --- a/public/assets/img/svg/octicon-dot.svg +++ b/public/assets/img/svg/octicon-dot.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-download.svg b/public/assets/img/svg/octicon-download.svg index f5ae4e389dc..80584198301 100644 --- a/public/assets/img/svg/octicon-download.svg +++ b/public/assets/img/svg/octicon-download.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-duplicate.svg b/public/assets/img/svg/octicon-duplicate.svg index 323c8f3df62..289ac5905f1 100644 --- a/public/assets/img/svg/octicon-duplicate.svg +++ b/public/assets/img/svg/octicon-duplicate.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-ellipsis.svg b/public/assets/img/svg/octicon-ellipsis.svg index 8f4da5d8054..152e6eb3249 100644 --- a/public/assets/img/svg/octicon-ellipsis.svg +++ b/public/assets/img/svg/octicon-ellipsis.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-eye-closed.svg b/public/assets/img/svg/octicon-eye-closed.svg index 2cfdf664065..3b493863cdc 100644 --- a/public/assets/img/svg/octicon-eye-closed.svg +++ b/public/assets/img/svg/octicon-eye-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-eye.svg b/public/assets/img/svg/octicon-eye.svg index 5f63e08dea5..c0b3648c630 100644 --- a/public/assets/img/svg/octicon-eye.svg +++ b/public/assets/img/svg/octicon-eye.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-discussion.svg b/public/assets/img/svg/octicon-feed-discussion.svg index 7ffa53cdca7..e8ccfff386a 100644 --- a/public/assets/img/svg/octicon-feed-discussion.svg +++ b/public/assets/img/svg/octicon-feed-discussion.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-forked.svg b/public/assets/img/svg/octicon-feed-forked.svg index c64a5a1a22a..65b0eb1b133 100644 --- a/public/assets/img/svg/octicon-feed-forked.svg +++ b/public/assets/img/svg/octicon-feed-forked.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-heart.svg b/public/assets/img/svg/octicon-feed-heart.svg index 3179473eeb1..f2d620dd47f 100644 --- a/public/assets/img/svg/octicon-feed-heart.svg +++ b/public/assets/img/svg/octicon-feed-heart.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-issue-closed.svg b/public/assets/img/svg/octicon-feed-issue-closed.svg index 4fea50bac65..9cd3127afc0 100644 --- a/public/assets/img/svg/octicon-feed-issue-closed.svg +++ b/public/assets/img/svg/octicon-feed-issue-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-issue-draft.svg b/public/assets/img/svg/octicon-feed-issue-draft.svg index 3720b5d9b3a..091a59163ed 100644 --- a/public/assets/img/svg/octicon-feed-issue-draft.svg +++ b/public/assets/img/svg/octicon-feed-issue-draft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-issue-open.svg b/public/assets/img/svg/octicon-feed-issue-open.svg index 4e497682e56..6d8989895b0 100644 --- a/public/assets/img/svg/octicon-feed-issue-open.svg +++ b/public/assets/img/svg/octicon-feed-issue-open.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-issue-reopen.svg b/public/assets/img/svg/octicon-feed-issue-reopen.svg new file mode 100644 index 00000000000..c82d5b0fdc6 --- /dev/null +++ b/public/assets/img/svg/octicon-feed-issue-reopen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-merged.svg b/public/assets/img/svg/octicon-feed-merged.svg index 4679ea202b7..2984bef2272 100644 --- a/public/assets/img/svg/octicon-feed-merged.svg +++ b/public/assets/img/svg/octicon-feed-merged.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-person.svg b/public/assets/img/svg/octicon-feed-person.svg index 7c9bbf4c37b..0854866216d 100644 --- a/public/assets/img/svg/octicon-feed-person.svg +++ b/public/assets/img/svg/octicon-feed-person.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-plus.svg b/public/assets/img/svg/octicon-feed-plus.svg index a453bf11e99..6d0286e22ea 100644 --- a/public/assets/img/svg/octicon-feed-plus.svg +++ b/public/assets/img/svg/octicon-feed-plus.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-public.svg b/public/assets/img/svg/octicon-feed-public.svg index 4decd913004..926f7fe26a6 100644 --- a/public/assets/img/svg/octicon-feed-public.svg +++ b/public/assets/img/svg/octicon-feed-public.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-pull-request-closed.svg b/public/assets/img/svg/octicon-feed-pull-request-closed.svg index 824a4e06ab3..b594acd9cc2 100644 --- a/public/assets/img/svg/octicon-feed-pull-request-closed.svg +++ b/public/assets/img/svg/octicon-feed-pull-request-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-pull-request-draft.svg b/public/assets/img/svg/octicon-feed-pull-request-draft.svg index 5091a4a0a9f..1ae02e68efc 100644 --- a/public/assets/img/svg/octicon-feed-pull-request-draft.svg +++ b/public/assets/img/svg/octicon-feed-pull-request-draft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-pull-request-open.svg b/public/assets/img/svg/octicon-feed-pull-request-open.svg index 276e02f925c..d1349c2d6c6 100644 --- a/public/assets/img/svg/octicon-feed-pull-request-open.svg +++ b/public/assets/img/svg/octicon-feed-pull-request-open.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-repo.svg b/public/assets/img/svg/octicon-feed-repo.svg index 69a1989476c..fe099c52d21 100644 --- a/public/assets/img/svg/octicon-feed-repo.svg +++ b/public/assets/img/svg/octicon-feed-repo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-rocket.svg b/public/assets/img/svg/octicon-feed-rocket.svg index 843560a9de6..48587a1ab6d 100644 --- a/public/assets/img/svg/octicon-feed-rocket.svg +++ b/public/assets/img/svg/octicon-feed-rocket.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-star.svg b/public/assets/img/svg/octicon-feed-star.svg index 1688aab55db..3c3a6aa48a6 100644 --- a/public/assets/img/svg/octicon-feed-star.svg +++ b/public/assets/img/svg/octicon-feed-star.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-tag.svg b/public/assets/img/svg/octicon-feed-tag.svg index c598c4d4c4b..d63dd74c4de 100644 --- a/public/assets/img/svg/octicon-feed-tag.svg +++ b/public/assets/img/svg/octicon-feed-tag.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-feed-trophy.svg b/public/assets/img/svg/octicon-feed-trophy.svg index 6dc8d5a56ba..ba06c3e712d 100644 --- a/public/assets/img/svg/octicon-feed-trophy.svg +++ b/public/assets/img/svg/octicon-feed-trophy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-added.svg b/public/assets/img/svg/octicon-file-added.svg index 927784d2e6f..a8cd80f3e8a 100644 --- a/public/assets/img/svg/octicon-file-added.svg +++ b/public/assets/img/svg/octicon-file-added.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-badge.svg b/public/assets/img/svg/octicon-file-badge.svg index 944b5227575..5f0a74206ad 100644 --- a/public/assets/img/svg/octicon-file-badge.svg +++ b/public/assets/img/svg/octicon-file-badge.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-binary.svg b/public/assets/img/svg/octicon-file-binary.svg index 45f4e86fb6e..492e7d54335 100644 --- a/public/assets/img/svg/octicon-file-binary.svg +++ b/public/assets/img/svg/octicon-file-binary.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-code.svg b/public/assets/img/svg/octicon-file-code.svg index 38baefd9e98..66430e30822 100644 --- a/public/assets/img/svg/octicon-file-code.svg +++ b/public/assets/img/svg/octicon-file-code.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-diff.svg b/public/assets/img/svg/octicon-file-diff.svg index 6fdf5c5735e..a58df3e53db 100644 --- a/public/assets/img/svg/octicon-file-diff.svg +++ b/public/assets/img/svg/octicon-file-diff.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-directory-fill.svg b/public/assets/img/svg/octicon-file-directory-fill.svg index f16ba39e0e8..800e6ba9528 100644 --- a/public/assets/img/svg/octicon-file-directory-fill.svg +++ b/public/assets/img/svg/octicon-file-directory-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-directory-open-fill.svg b/public/assets/img/svg/octicon-file-directory-open-fill.svg index ca7a4adf6b4..0d1bac328a7 100644 --- a/public/assets/img/svg/octicon-file-directory-open-fill.svg +++ b/public/assets/img/svg/octicon-file-directory-open-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-directory-symlink.svg b/public/assets/img/svg/octicon-file-directory-symlink.svg index ddc2e3fd670..8a6142b2299 100644 --- a/public/assets/img/svg/octicon-file-directory-symlink.svg +++ b/public/assets/img/svg/octicon-file-directory-symlink.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-moved.svg b/public/assets/img/svg/octicon-file-moved.svg index 86670ef6ce9..03735b0e987 100644 --- a/public/assets/img/svg/octicon-file-moved.svg +++ b/public/assets/img/svg/octicon-file-moved.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-submodule.svg b/public/assets/img/svg/octicon-file-submodule.svg index ba947cc988d..8eab90f8c6e 100644 --- a/public/assets/img/svg/octicon-file-submodule.svg +++ b/public/assets/img/svg/octicon-file-submodule.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-symlink-file.svg b/public/assets/img/svg/octicon-file-symlink-file.svg index bc712b5ba1a..21b8cbf5165 100644 --- a/public/assets/img/svg/octicon-file-symlink-file.svg +++ b/public/assets/img/svg/octicon-file-symlink-file.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file-zip.svg b/public/assets/img/svg/octicon-file-zip.svg index 2f025030544..3adddf0aa16 100644 --- a/public/assets/img/svg/octicon-file-zip.svg +++ b/public/assets/img/svg/octicon-file-zip.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-file.svg b/public/assets/img/svg/octicon-file.svg index 976d8d99d3e..faf92c5431c 100644 --- a/public/assets/img/svg/octicon-file.svg +++ b/public/assets/img/svg/octicon-file.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-filter-remove.svg b/public/assets/img/svg/octicon-filter-remove.svg new file mode 100644 index 00000000000..c10010622bc --- /dev/null +++ b/public/assets/img/svg/octicon-filter-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-filter.svg b/public/assets/img/svg/octicon-filter.svg index f93cc0159b4..63cd16e647c 100644 --- a/public/assets/img/svg/octicon-filter.svg +++ b/public/assets/img/svg/octicon-filter.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-fiscal-host.svg b/public/assets/img/svg/octicon-fiscal-host.svg index 67f683604bf..877850ad243 100644 --- a/public/assets/img/svg/octicon-fiscal-host.svg +++ b/public/assets/img/svg/octicon-fiscal-host.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-flame.svg b/public/assets/img/svg/octicon-flame.svg index 6fa5050d6b8..0db84ac379d 100644 --- a/public/assets/img/svg/octicon-flame.svg +++ b/public/assets/img/svg/octicon-flame.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-fold-down.svg b/public/assets/img/svg/octicon-fold-down.svg index 22e6ad1d442..957a95fceac 100644 --- a/public/assets/img/svg/octicon-fold-down.svg +++ b/public/assets/img/svg/octicon-fold-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-fold-up.svg b/public/assets/img/svg/octicon-fold-up.svg index 2b7d4bdf8dc..c139cf83620 100644 --- a/public/assets/img/svg/octicon-fold-up.svg +++ b/public/assets/img/svg/octicon-fold-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-fold.svg b/public/assets/img/svg/octicon-fold.svg index 7fd4e9d56de..5657e930c1b 100644 --- a/public/assets/img/svg/octicon-fold.svg +++ b/public/assets/img/svg/octicon-fold.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-gear.svg b/public/assets/img/svg/octicon-gear.svg index 6f5e36af10e..be6eee1b8aa 100644 --- a/public/assets/img/svg/octicon-gear.svg +++ b/public/assets/img/svg/octicon-gear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-gift.svg b/public/assets/img/svg/octicon-gift.svg index 866461c4532..4a6ba3049a1 100644 --- a/public/assets/img/svg/octicon-gift.svg +++ b/public/assets/img/svg/octicon-gift.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-branch.svg b/public/assets/img/svg/octicon-git-branch.svg index fdd05567b4b..c7116adf010 100644 --- a/public/assets/img/svg/octicon-git-branch.svg +++ b/public/assets/img/svg/octicon-git-branch.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-commit.svg b/public/assets/img/svg/octicon-git-commit.svg index 6e5b5fc07f4..6c2ac50ac3d 100644 --- a/public/assets/img/svg/octicon-git-commit.svg +++ b/public/assets/img/svg/octicon-git-commit.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-compare.svg b/public/assets/img/svg/octicon-git-compare.svg index 3c61c102570..6bf455942d1 100644 --- a/public/assets/img/svg/octicon-git-compare.svg +++ b/public/assets/img/svg/octicon-git-compare.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-merge-queue.svg b/public/assets/img/svg/octicon-git-merge-queue.svg index 4890a68d8cf..bfe39b34e6b 100644 --- a/public/assets/img/svg/octicon-git-merge-queue.svg +++ b/public/assets/img/svg/octicon-git-merge-queue.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-merge.svg b/public/assets/img/svg/octicon-git-merge.svg index 6aa3d4ce4e8..17299614771 100644 --- a/public/assets/img/svg/octicon-git-merge.svg +++ b/public/assets/img/svg/octicon-git-merge.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-pull-request-closed.svg b/public/assets/img/svg/octicon-git-pull-request-closed.svg index 47b18f87e92..628f9fe6daa 100644 --- a/public/assets/img/svg/octicon-git-pull-request-closed.svg +++ b/public/assets/img/svg/octicon-git-pull-request-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-pull-request-draft.svg b/public/assets/img/svg/octicon-git-pull-request-draft.svg index 43ebe4569c8..74d9af987a4 100644 --- a/public/assets/img/svg/octicon-git-pull-request-draft.svg +++ b/public/assets/img/svg/octicon-git-pull-request-draft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-git-pull-request.svg b/public/assets/img/svg/octicon-git-pull-request.svg index e9965677d26..2277666fe04 100644 --- a/public/assets/img/svg/octicon-git-pull-request.svg +++ b/public/assets/img/svg/octicon-git-pull-request.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-globe.svg b/public/assets/img/svg/octicon-globe.svg index a90f1d16ad3..60ca5b57e37 100644 --- a/public/assets/img/svg/octicon-globe.svg +++ b/public/assets/img/svg/octicon-globe.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-goal.svg b/public/assets/img/svg/octicon-goal.svg index 46f224661f8..dd36a51fb7b 100644 --- a/public/assets/img/svg/octicon-goal.svg +++ b/public/assets/img/svg/octicon-goal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-grabber.svg b/public/assets/img/svg/octicon-grabber.svg index 33cc2478197..9239188d424 100644 --- a/public/assets/img/svg/octicon-grabber.svg +++ b/public/assets/img/svg/octicon-grabber.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-graph.svg b/public/assets/img/svg/octicon-graph.svg index fa108db0a0a..393faf95bd1 100644 --- a/public/assets/img/svg/octicon-graph.svg +++ b/public/assets/img/svg/octicon-graph.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-hash.svg b/public/assets/img/svg/octicon-hash.svg index 3c74070d2b1..9920504192c 100644 --- a/public/assets/img/svg/octicon-hash.svg +++ b/public/assets/img/svg/octicon-hash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-heading.svg b/public/assets/img/svg/octicon-heading.svg index 9caceb6157a..597e7949a50 100644 --- a/public/assets/img/svg/octicon-heading.svg +++ b/public/assets/img/svg/octicon-heading.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-heart-fill.svg b/public/assets/img/svg/octicon-heart-fill.svg index 0665a168946..1f23ef46da5 100644 --- a/public/assets/img/svg/octicon-heart-fill.svg +++ b/public/assets/img/svg/octicon-heart-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-heart.svg b/public/assets/img/svg/octicon-heart.svg index 9f178cfdedb..3980b80b521 100644 --- a/public/assets/img/svg/octicon-heart.svg +++ b/public/assets/img/svg/octicon-heart.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-history.svg b/public/assets/img/svg/octicon-history.svg index 72526f113ce..fb835dc4aa3 100644 --- a/public/assets/img/svg/octicon-history.svg +++ b/public/assets/img/svg/octicon-history.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-home.svg b/public/assets/img/svg/octicon-home.svg index 0ebd98fccb2..2586237e1f8 100644 --- a/public/assets/img/svg/octicon-home.svg +++ b/public/assets/img/svg/octicon-home.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-horizontal-rule.svg b/public/assets/img/svg/octicon-horizontal-rule.svg index 1cdc4a8ccf1..978874be5a4 100644 --- a/public/assets/img/svg/octicon-horizontal-rule.svg +++ b/public/assets/img/svg/octicon-horizontal-rule.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-hourglass.svg b/public/assets/img/svg/octicon-hourglass.svg index 815ddcd1c10..8f84421c9e9 100644 --- a/public/assets/img/svg/octicon-hourglass.svg +++ b/public/assets/img/svg/octicon-hourglass.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-hubot.svg b/public/assets/img/svg/octicon-hubot.svg index 6ba0c672e69..00423896482 100644 --- a/public/assets/img/svg/octicon-hubot.svg +++ b/public/assets/img/svg/octicon-hubot.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-id-badge.svg b/public/assets/img/svg/octicon-id-badge.svg index 927d780883b..ed3acea8bf6 100644 --- a/public/assets/img/svg/octicon-id-badge.svg +++ b/public/assets/img/svg/octicon-id-badge.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-image.svg b/public/assets/img/svg/octicon-image.svg index 47b70c1663a..a3ce77a876a 100644 --- a/public/assets/img/svg/octicon-image.svg +++ b/public/assets/img/svg/octicon-image.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-inbox.svg b/public/assets/img/svg/octicon-inbox.svg index 42cc08fa80d..3d65320ac6a 100644 --- a/public/assets/img/svg/octicon-inbox.svg +++ b/public/assets/img/svg/octicon-inbox.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-infinity.svg b/public/assets/img/svg/octicon-infinity.svg index 7e52e4ad6e7..2edd1ef91b6 100644 --- a/public/assets/img/svg/octicon-infinity.svg +++ b/public/assets/img/svg/octicon-infinity.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-info.svg b/public/assets/img/svg/octicon-info.svg index ab4b4c59708..de6616b0b7b 100644 --- a/public/assets/img/svg/octicon-info.svg +++ b/public/assets/img/svg/octicon-info.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-closed.svg b/public/assets/img/svg/octicon-issue-closed.svg index 51be962aac9..1d0aa0c2b42 100644 --- a/public/assets/img/svg/octicon-issue-closed.svg +++ b/public/assets/img/svg/octicon-issue-closed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-draft.svg b/public/assets/img/svg/octicon-issue-draft.svg index 8ec85824796..d02ddd3e0c8 100644 --- a/public/assets/img/svg/octicon-issue-draft.svg +++ b/public/assets/img/svg/octicon-issue-draft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-opened.svg b/public/assets/img/svg/octicon-issue-opened.svg index 9f60583d99e..fb0752dcf3c 100644 --- a/public/assets/img/svg/octicon-issue-opened.svg +++ b/public/assets/img/svg/octicon-issue-opened.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-reopened.svg b/public/assets/img/svg/octicon-issue-reopened.svg index 48eebc2e0e2..cd72facc374 100644 --- a/public/assets/img/svg/octicon-issue-reopened.svg +++ b/public/assets/img/svg/octicon-issue-reopened.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-tracked-by.svg b/public/assets/img/svg/octicon-issue-tracked-by.svg index 2fd4c95f3ed..3cabd7851d2 100644 --- a/public/assets/img/svg/octicon-issue-tracked-by.svg +++ b/public/assets/img/svg/octicon-issue-tracked-by.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-issue-tracks.svg b/public/assets/img/svg/octicon-issue-tracks.svg index 503ed5cdafc..7eb86e51519 100644 --- a/public/assets/img/svg/octicon-issue-tracks.svg +++ b/public/assets/img/svg/octicon-issue-tracks.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-italic.svg b/public/assets/img/svg/octicon-italic.svg index 11237486474..2f71fcc9331 100644 --- a/public/assets/img/svg/octicon-italic.svg +++ b/public/assets/img/svg/octicon-italic.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-iterations.svg b/public/assets/img/svg/octicon-iterations.svg index a8a6a2555ee..33e98c1de36 100644 --- a/public/assets/img/svg/octicon-iterations.svg +++ b/public/assets/img/svg/octicon-iterations.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-kebab-horizontal.svg b/public/assets/img/svg/octicon-kebab-horizontal.svg index faa581329fa..d744abf8d81 100644 --- a/public/assets/img/svg/octicon-kebab-horizontal.svg +++ b/public/assets/img/svg/octicon-kebab-horizontal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-key-asterisk.svg b/public/assets/img/svg/octicon-key-asterisk.svg index f04c044590e..8b57e47885e 100644 --- a/public/assets/img/svg/octicon-key-asterisk.svg +++ b/public/assets/img/svg/octicon-key-asterisk.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-key.svg b/public/assets/img/svg/octicon-key.svg index 10c0b1aa8ac..6705b713832 100644 --- a/public/assets/img/svg/octicon-key.svg +++ b/public/assets/img/svg/octicon-key.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-law.svg b/public/assets/img/svg/octicon-law.svg index 8df6eec3bbd..841798e644a 100644 --- a/public/assets/img/svg/octicon-law.svg +++ b/public/assets/img/svg/octicon-law.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-light-bulb.svg b/public/assets/img/svg/octicon-light-bulb.svg index f3c58d47a5a..c438ecf25e0 100644 --- a/public/assets/img/svg/octicon-light-bulb.svg +++ b/public/assets/img/svg/octicon-light-bulb.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-link-external.svg b/public/assets/img/svg/octicon-link-external.svg index 4479d3aac0a..6d7750b9d26 100644 --- a/public/assets/img/svg/octicon-link-external.svg +++ b/public/assets/img/svg/octicon-link-external.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-link.svg b/public/assets/img/svg/octicon-link.svg index e6b60a12f70..9269974e490 100644 --- a/public/assets/img/svg/octicon-link.svg +++ b/public/assets/img/svg/octicon-link.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-list-ordered.svg b/public/assets/img/svg/octicon-list-ordered.svg index dabed4edce4..004070815a9 100644 --- a/public/assets/img/svg/octicon-list-ordered.svg +++ b/public/assets/img/svg/octicon-list-ordered.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-list-unordered.svg b/public/assets/img/svg/octicon-list-unordered.svg index 32640eca971..1976bd89db9 100644 --- a/public/assets/img/svg/octicon-list-unordered.svg +++ b/public/assets/img/svg/octicon-list-unordered.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-location.svg b/public/assets/img/svg/octicon-location.svg index 81c3ed60d4b..7f91acc2e70 100644 --- a/public/assets/img/svg/octicon-location.svg +++ b/public/assets/img/svg/octicon-location.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-lock.svg b/public/assets/img/svg/octicon-lock.svg index 3e3dae06caf..b737b56f771 100644 --- a/public/assets/img/svg/octicon-lock.svg +++ b/public/assets/img/svg/octicon-lock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-log.svg b/public/assets/img/svg/octicon-log.svg index 21c263e7926..0f71230f069 100644 --- a/public/assets/img/svg/octicon-log.svg +++ b/public/assets/img/svg/octicon-log.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-logo-gist.svg b/public/assets/img/svg/octicon-logo-gist.svg index 861764a6634..8621f14776d 100644 --- a/public/assets/img/svg/octicon-logo-gist.svg +++ b/public/assets/img/svg/octicon-logo-gist.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-logo-github.svg b/public/assets/img/svg/octicon-logo-github.svg index 546a7cd2529..02d92c9b13f 100644 --- a/public/assets/img/svg/octicon-logo-github.svg +++ b/public/assets/img/svg/octicon-logo-github.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mail.svg b/public/assets/img/svg/octicon-mail.svg index 6a6a036410d..750b742e6c1 100644 --- a/public/assets/img/svg/octicon-mail.svg +++ b/public/assets/img/svg/octicon-mail.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mark-github.svg b/public/assets/img/svg/octicon-mark-github.svg index 0e5bf3b4d67..9381053c066 100644 --- a/public/assets/img/svg/octicon-mark-github.svg +++ b/public/assets/img/svg/octicon-mark-github.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-megaphone.svg b/public/assets/img/svg/octicon-megaphone.svg index f2a69adb7d7..178b55092b1 100644 --- a/public/assets/img/svg/octicon-megaphone.svg +++ b/public/assets/img/svg/octicon-megaphone.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mention.svg b/public/assets/img/svg/octicon-mention.svg index 60667575583..75b414e123d 100644 --- a/public/assets/img/svg/octicon-mention.svg +++ b/public/assets/img/svg/octicon-mention.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-meter.svg b/public/assets/img/svg/octicon-meter.svg index d60c0689877..38bd456445e 100644 --- a/public/assets/img/svg/octicon-meter.svg +++ b/public/assets/img/svg/octicon-meter.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-milestone.svg b/public/assets/img/svg/octicon-milestone.svg index 69da41891d6..19667b613cf 100644 --- a/public/assets/img/svg/octicon-milestone.svg +++ b/public/assets/img/svg/octicon-milestone.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mirror.svg b/public/assets/img/svg/octicon-mirror.svg index 0acd01b01b9..d9c67fcf84b 100644 --- a/public/assets/img/svg/octicon-mirror.svg +++ b/public/assets/img/svg/octicon-mirror.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-moon.svg b/public/assets/img/svg/octicon-moon.svg index a51e2230348..244544d6c1b 100644 --- a/public/assets/img/svg/octicon-moon.svg +++ b/public/assets/img/svg/octicon-moon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mortar-board.svg b/public/assets/img/svg/octicon-mortar-board.svg index 871d1ae7025..8a9f9545466 100644 --- a/public/assets/img/svg/octicon-mortar-board.svg +++ b/public/assets/img/svg/octicon-mortar-board.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-move-to-bottom.svg b/public/assets/img/svg/octicon-move-to-bottom.svg index 3fb8f973915..3f2a183df99 100644 --- a/public/assets/img/svg/octicon-move-to-bottom.svg +++ b/public/assets/img/svg/octicon-move-to-bottom.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-move-to-end.svg b/public/assets/img/svg/octicon-move-to-end.svg index d4b5cb62bf9..ef3e60bc3a3 100644 --- a/public/assets/img/svg/octicon-move-to-end.svg +++ b/public/assets/img/svg/octicon-move-to-end.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-move-to-start.svg b/public/assets/img/svg/octicon-move-to-start.svg index 00a24a4c9f6..2dc1df7344b 100644 --- a/public/assets/img/svg/octicon-move-to-start.svg +++ b/public/assets/img/svg/octicon-move-to-start.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-move-to-top.svg b/public/assets/img/svg/octicon-move-to-top.svg index f0726dd9888..109515cc52a 100644 --- a/public/assets/img/svg/octicon-move-to-top.svg +++ b/public/assets/img/svg/octicon-move-to-top.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-multi-select.svg b/public/assets/img/svg/octicon-multi-select.svg index cebcebce6c3..e079b24a5ed 100644 --- a/public/assets/img/svg/octicon-multi-select.svg +++ b/public/assets/img/svg/octicon-multi-select.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-mute.svg b/public/assets/img/svg/octicon-mute.svg index db465e41d51..2bb114f8a56 100644 --- a/public/assets/img/svg/octicon-mute.svg +++ b/public/assets/img/svg/octicon-mute.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-no-entry.svg b/public/assets/img/svg/octicon-no-entry.svg index ac897b84b48..e7117cd1ca6 100644 --- a/public/assets/img/svg/octicon-no-entry.svg +++ b/public/assets/img/svg/octicon-no-entry.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-north-star.svg b/public/assets/img/svg/octicon-north-star.svg index 69b64feebaf..2fef71871aa 100644 --- a/public/assets/img/svg/octicon-north-star.svg +++ b/public/assets/img/svg/octicon-north-star.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-note.svg b/public/assets/img/svg/octicon-note.svg index d3eb92b3b54..39e7e4e7e55 100644 --- a/public/assets/img/svg/octicon-note.svg +++ b/public/assets/img/svg/octicon-note.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-number.svg b/public/assets/img/svg/octicon-number.svg index 22cf4684684..0a88de18aa8 100644 --- a/public/assets/img/svg/octicon-number.svg +++ b/public/assets/img/svg/octicon-number.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-organization.svg b/public/assets/img/svg/octicon-organization.svg index beef876a970..0799b073110 100644 --- a/public/assets/img/svg/octicon-organization.svg +++ b/public/assets/img/svg/octicon-organization.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-package-dependencies.svg b/public/assets/img/svg/octicon-package-dependencies.svg index ae408868b8c..8cb567153ba 100644 --- a/public/assets/img/svg/octicon-package-dependencies.svg +++ b/public/assets/img/svg/octicon-package-dependencies.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-package-dependents.svg b/public/assets/img/svg/octicon-package-dependents.svg index bad01efc9b4..22dd4d1626f 100644 --- a/public/assets/img/svg/octicon-package-dependents.svg +++ b/public/assets/img/svg/octicon-package-dependents.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-package.svg b/public/assets/img/svg/octicon-package.svg index aab1e40c4d4..61b222508c7 100644 --- a/public/assets/img/svg/octicon-package.svg +++ b/public/assets/img/svg/octicon-package.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-paintbrush.svg b/public/assets/img/svg/octicon-paintbrush.svg index 8cbfcf3ee4a..d9ac07654c6 100644 --- a/public/assets/img/svg/octicon-paintbrush.svg +++ b/public/assets/img/svg/octicon-paintbrush.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-paperclip.svg b/public/assets/img/svg/octicon-paperclip.svg index 326c8b8c3ff..de38702a44f 100644 --- a/public/assets/img/svg/octicon-paperclip.svg +++ b/public/assets/img/svg/octicon-paperclip.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-passkey-fill.svg b/public/assets/img/svg/octicon-passkey-fill.svg index bca3a247572..98fcafb7722 100644 --- a/public/assets/img/svg/octicon-passkey-fill.svg +++ b/public/assets/img/svg/octicon-passkey-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-paste.svg b/public/assets/img/svg/octicon-paste.svg index 1b3ee09ce1b..212c2b82aa6 100644 --- a/public/assets/img/svg/octicon-paste.svg +++ b/public/assets/img/svg/octicon-paste.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-pencil.svg b/public/assets/img/svg/octicon-pencil.svg index 41c638fcedb..f0f1f7387cc 100644 --- a/public/assets/img/svg/octicon-pencil.svg +++ b/public/assets/img/svg/octicon-pencil.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-people.svg b/public/assets/img/svg/octicon-people.svg index 14e9d75d4dd..9143c70a46d 100644 --- a/public/assets/img/svg/octicon-people.svg +++ b/public/assets/img/svg/octicon-people.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-person-add.svg b/public/assets/img/svg/octicon-person-add.svg index a04727ce0bf..4c9517269d3 100644 --- a/public/assets/img/svg/octicon-person-add.svg +++ b/public/assets/img/svg/octicon-person-add.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-person-fill.svg b/public/assets/img/svg/octicon-person-fill.svg index aef50598071..4715c29f03b 100644 --- a/public/assets/img/svg/octicon-person-fill.svg +++ b/public/assets/img/svg/octicon-person-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-person.svg b/public/assets/img/svg/octicon-person.svg index 0c18220b418..2d12f023779 100644 --- a/public/assets/img/svg/octicon-person.svg +++ b/public/assets/img/svg/octicon-person.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-pin-slash.svg b/public/assets/img/svg/octicon-pin-slash.svg index 4cd88d58a14..adf7ed4a7ab 100644 --- a/public/assets/img/svg/octicon-pin-slash.svg +++ b/public/assets/img/svg/octicon-pin-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-pin.svg b/public/assets/img/svg/octicon-pin.svg index e24d9cc817d..49ac5af31ac 100644 --- a/public/assets/img/svg/octicon-pin.svg +++ b/public/assets/img/svg/octicon-pin.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-pivot-column.svg b/public/assets/img/svg/octicon-pivot-column.svg index 34370c20600..795fde10d14 100644 --- a/public/assets/img/svg/octicon-pivot-column.svg +++ b/public/assets/img/svg/octicon-pivot-column.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-play.svg b/public/assets/img/svg/octicon-play.svg index 39a3650dfdc..dca65727818 100644 --- a/public/assets/img/svg/octicon-play.svg +++ b/public/assets/img/svg/octicon-play.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-plug.svg b/public/assets/img/svg/octicon-plug.svg index e496b584d36..4caf972b676 100644 --- a/public/assets/img/svg/octicon-plug.svg +++ b/public/assets/img/svg/octicon-plug.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-plus-circle.svg b/public/assets/img/svg/octicon-plus-circle.svg index d8f4cee27d6..71c55630a82 100644 --- a/public/assets/img/svg/octicon-plus-circle.svg +++ b/public/assets/img/svg/octicon-plus-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-plus.svg b/public/assets/img/svg/octicon-plus.svg index 227b946e351..1fd3743532f 100644 --- a/public/assets/img/svg/octicon-plus.svg +++ b/public/assets/img/svg/octicon-plus.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-project-roadmap.svg b/public/assets/img/svg/octicon-project-roadmap.svg index a61cd569b63..a6b15c1ff2c 100644 --- a/public/assets/img/svg/octicon-project-roadmap.svg +++ b/public/assets/img/svg/octicon-project-roadmap.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-project-symlink.svg b/public/assets/img/svg/octicon-project-symlink.svg index 0ca269236b0..bc9104a8715 100644 --- a/public/assets/img/svg/octicon-project-symlink.svg +++ b/public/assets/img/svg/octicon-project-symlink.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-project-template.svg b/public/assets/img/svg/octicon-project-template.svg index 8a7013a7a02..31d4cc06b7e 100644 --- a/public/assets/img/svg/octicon-project-template.svg +++ b/public/assets/img/svg/octicon-project-template.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-project.svg b/public/assets/img/svg/octicon-project.svg index 52d86d4b77d..9fb23c758da 100644 --- a/public/assets/img/svg/octicon-project.svg +++ b/public/assets/img/svg/octicon-project.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-pulse.svg b/public/assets/img/svg/octicon-pulse.svg index b164f2ea762..2450ffeabe8 100644 --- a/public/assets/img/svg/octicon-pulse.svg +++ b/public/assets/img/svg/octicon-pulse.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-question.svg b/public/assets/img/svg/octicon-question.svg index b21e8675257..6d6a3f53537 100644 --- a/public/assets/img/svg/octicon-question.svg +++ b/public/assets/img/svg/octicon-question.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-quote.svg b/public/assets/img/svg/octicon-quote.svg index 2647b0bf47a..c3b02f48314 100644 --- a/public/assets/img/svg/octicon-quote.svg +++ b/public/assets/img/svg/octicon-quote.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-read.svg b/public/assets/img/svg/octicon-read.svg index dc27b12ae2f..cd5ee2028d8 100644 --- a/public/assets/img/svg/octicon-read.svg +++ b/public/assets/img/svg/octicon-read.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-redo.svg b/public/assets/img/svg/octicon-redo.svg index a4fbeabeaa2..a81a32131f2 100644 --- a/public/assets/img/svg/octicon-redo.svg +++ b/public/assets/img/svg/octicon-redo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-rel-file-path.svg b/public/assets/img/svg/octicon-rel-file-path.svg index 4f235bae2e6..45837c2cd39 100644 --- a/public/assets/img/svg/octicon-rel-file-path.svg +++ b/public/assets/img/svg/octicon-rel-file-path.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-reply.svg b/public/assets/img/svg/octicon-reply.svg index 124511dae2f..70e550e5d76 100644 --- a/public/assets/img/svg/octicon-reply.svg +++ b/public/assets/img/svg/octicon-reply.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-clone.svg b/public/assets/img/svg/octicon-repo-clone.svg index 72e245fc342..67099d8170e 100644 --- a/public/assets/img/svg/octicon-repo-clone.svg +++ b/public/assets/img/svg/octicon-repo-clone.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-deleted.svg b/public/assets/img/svg/octicon-repo-deleted.svg index 59fbeaff625..c79e3be28c9 100644 --- a/public/assets/img/svg/octicon-repo-deleted.svg +++ b/public/assets/img/svg/octicon-repo-deleted.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-forked.svg b/public/assets/img/svg/octicon-repo-forked.svg index 08bd8e5a70f..a45bee68ca0 100644 --- a/public/assets/img/svg/octicon-repo-forked.svg +++ b/public/assets/img/svg/octicon-repo-forked.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-locked.svg b/public/assets/img/svg/octicon-repo-locked.svg index 382a1f0a786..c6def5b5142 100644 --- a/public/assets/img/svg/octicon-repo-locked.svg +++ b/public/assets/img/svg/octicon-repo-locked.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-pull.svg b/public/assets/img/svg/octicon-repo-pull.svg index 743aeec31d2..4f02f6f127f 100644 --- a/public/assets/img/svg/octicon-repo-pull.svg +++ b/public/assets/img/svg/octicon-repo-pull.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-push.svg b/public/assets/img/svg/octicon-repo-push.svg index f473dcb2b9f..07bd7626e27 100644 --- a/public/assets/img/svg/octicon-repo-push.svg +++ b/public/assets/img/svg/octicon-repo-push.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo-template.svg b/public/assets/img/svg/octicon-repo-template.svg index 12da1960e08..45b7acf4f9a 100644 --- a/public/assets/img/svg/octicon-repo-template.svg +++ b/public/assets/img/svg/octicon-repo-template.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-repo.svg b/public/assets/img/svg/octicon-repo.svg index c237a5f1f47..ace4a3c72d1 100644 --- a/public/assets/img/svg/octicon-repo.svg +++ b/public/assets/img/svg/octicon-repo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-report.svg b/public/assets/img/svg/octicon-report.svg index 615c52b20d2..e0cf5651363 100644 --- a/public/assets/img/svg/octicon-report.svg +++ b/public/assets/img/svg/octicon-report.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-rocket.svg b/public/assets/img/svg/octicon-rocket.svg index e3a5f805d1b..13395eb5001 100644 --- a/public/assets/img/svg/octicon-rocket.svg +++ b/public/assets/img/svg/octicon-rocket.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-rows.svg b/public/assets/img/svg/octicon-rows.svg index 9cd752698da..4596215238a 100644 --- a/public/assets/img/svg/octicon-rows.svg +++ b/public/assets/img/svg/octicon-rows.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-rss.svg b/public/assets/img/svg/octicon-rss.svg index d9aa600d333..6b1303340aa 100644 --- a/public/assets/img/svg/octicon-rss.svg +++ b/public/assets/img/svg/octicon-rss.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-ruby.svg b/public/assets/img/svg/octicon-ruby.svg index b4f9f426f4b..3697948a685 100644 --- a/public/assets/img/svg/octicon-ruby.svg +++ b/public/assets/img/svg/octicon-ruby.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-screen-full.svg b/public/assets/img/svg/octicon-screen-full.svg index 040ddf9ed99..8074a22cf2d 100644 --- a/public/assets/img/svg/octicon-screen-full.svg +++ b/public/assets/img/svg/octicon-screen-full.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-screen-normal.svg b/public/assets/img/svg/octicon-screen-normal.svg index 1cf1e086891..98fe6a87219 100644 --- a/public/assets/img/svg/octicon-screen-normal.svg +++ b/public/assets/img/svg/octicon-screen-normal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-search.svg b/public/assets/img/svg/octicon-search.svg index 5c3a88bca6a..5286c0440c9 100644 --- a/public/assets/img/svg/octicon-search.svg +++ b/public/assets/img/svg/octicon-search.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-server.svg b/public/assets/img/svg/octicon-server.svg index 3d80f856bad..fd4e9be060a 100644 --- a/public/assets/img/svg/octicon-server.svg +++ b/public/assets/img/svg/octicon-server.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-share-android.svg b/public/assets/img/svg/octicon-share-android.svg index 81d0df456e7..2e1cdcbde50 100644 --- a/public/assets/img/svg/octicon-share-android.svg +++ b/public/assets/img/svg/octicon-share-android.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-shield-check.svg b/public/assets/img/svg/octicon-shield-check.svg index 13df0052fb3..99fc924261e 100644 --- a/public/assets/img/svg/octicon-shield-check.svg +++ b/public/assets/img/svg/octicon-shield-check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-shield-lock.svg b/public/assets/img/svg/octicon-shield-lock.svg index cc85d282110..2057bbc8f93 100644 --- a/public/assets/img/svg/octicon-shield-lock.svg +++ b/public/assets/img/svg/octicon-shield-lock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-shield-slash.svg b/public/assets/img/svg/octicon-shield-slash.svg index b4c2fe18df4..28a2b858471 100644 --- a/public/assets/img/svg/octicon-shield-slash.svg +++ b/public/assets/img/svg/octicon-shield-slash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-shield-x.svg b/public/assets/img/svg/octicon-shield-x.svg index a87b9c189cb..b0fff660353 100644 --- a/public/assets/img/svg/octicon-shield-x.svg +++ b/public/assets/img/svg/octicon-shield-x.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-shield.svg b/public/assets/img/svg/octicon-shield.svg index be209575db5..865238e563f 100644 --- a/public/assets/img/svg/octicon-shield.svg +++ b/public/assets/img/svg/octicon-shield.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sidebar-collapse.svg b/public/assets/img/svg/octicon-sidebar-collapse.svg index 7b307bdda27..6885cd277f0 100644 --- a/public/assets/img/svg/octicon-sidebar-collapse.svg +++ b/public/assets/img/svg/octicon-sidebar-collapse.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sidebar-expand.svg b/public/assets/img/svg/octicon-sidebar-expand.svg index 42816121a6e..41e5c800f17 100644 --- a/public/assets/img/svg/octicon-sidebar-expand.svg +++ b/public/assets/img/svg/octicon-sidebar-expand.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sign-in.svg b/public/assets/img/svg/octicon-sign-in.svg index f44aa4a614a..308c1187830 100644 --- a/public/assets/img/svg/octicon-sign-in.svg +++ b/public/assets/img/svg/octicon-sign-in.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sign-out.svg b/public/assets/img/svg/octicon-sign-out.svg index b703ae8b569..ac1c95f9939 100644 --- a/public/assets/img/svg/octicon-sign-out.svg +++ b/public/assets/img/svg/octicon-sign-out.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-skip-fill.svg b/public/assets/img/svg/octicon-skip-fill.svg index d3e854314e1..01a5cc8d3f0 100644 --- a/public/assets/img/svg/octicon-skip-fill.svg +++ b/public/assets/img/svg/octicon-skip-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-skip.svg b/public/assets/img/svg/octicon-skip.svg index 97905054381..fe8d37792ee 100644 --- a/public/assets/img/svg/octicon-skip.svg +++ b/public/assets/img/svg/octicon-skip.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sliders.svg b/public/assets/img/svg/octicon-sliders.svg index a76bf5180f7..940a3abde17 100644 --- a/public/assets/img/svg/octicon-sliders.svg +++ b/public/assets/img/svg/octicon-sliders.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-smiley.svg b/public/assets/img/svg/octicon-smiley.svg index 619b0960f76..11c9055fd03 100644 --- a/public/assets/img/svg/octicon-smiley.svg +++ b/public/assets/img/svg/octicon-smiley.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sort-asc.svg b/public/assets/img/svg/octicon-sort-asc.svg index fe05e58ed87..76fe377cb86 100644 --- a/public/assets/img/svg/octicon-sort-asc.svg +++ b/public/assets/img/svg/octicon-sort-asc.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sort-desc.svg b/public/assets/img/svg/octicon-sort-desc.svg index b35567d75ff..1ae84a7fe7d 100644 --- a/public/assets/img/svg/octicon-sort-desc.svg +++ b/public/assets/img/svg/octicon-sort-desc.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sparkle-fill.svg b/public/assets/img/svg/octicon-sparkle-fill.svg index 3b8a7d276f3..fafd3d839e8 100644 --- a/public/assets/img/svg/octicon-sparkle-fill.svg +++ b/public/assets/img/svg/octicon-sparkle-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sponsor-tiers.svg b/public/assets/img/svg/octicon-sponsor-tiers.svg index 08e0ae6e601..efe96cd5a8c 100644 --- a/public/assets/img/svg/octicon-sponsor-tiers.svg +++ b/public/assets/img/svg/octicon-sponsor-tiers.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-square-fill.svg b/public/assets/img/svg/octicon-square-fill.svg index 24deb106db8..06d32b3c78d 100644 --- a/public/assets/img/svg/octicon-square-fill.svg +++ b/public/assets/img/svg/octicon-square-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-squirrel.svg b/public/assets/img/svg/octicon-squirrel.svg index 4d04ca8061d..60b3ba5c584 100644 --- a/public/assets/img/svg/octicon-squirrel.svg +++ b/public/assets/img/svg/octicon-squirrel.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-stack.svg b/public/assets/img/svg/octicon-stack.svg index 683c6c4e2d0..a86dbbee8d1 100644 --- a/public/assets/img/svg/octicon-stack.svg +++ b/public/assets/img/svg/octicon-stack.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-star-fill.svg b/public/assets/img/svg/octicon-star-fill.svg index 3d5c976fef1..174ae0c2c07 100644 --- a/public/assets/img/svg/octicon-star-fill.svg +++ b/public/assets/img/svg/octicon-star-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-star.svg b/public/assets/img/svg/octicon-star.svg index 42e42ab5e6f..2001b8c0e86 100644 --- a/public/assets/img/svg/octicon-star.svg +++ b/public/assets/img/svg/octicon-star.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-stop.svg b/public/assets/img/svg/octicon-stop.svg index 03cdceb1f21..d85c7a35c2c 100644 --- a/public/assets/img/svg/octicon-stop.svg +++ b/public/assets/img/svg/octicon-stop.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-stopwatch.svg b/public/assets/img/svg/octicon-stopwatch.svg index b63aba3421d..eeec8dc396a 100644 --- a/public/assets/img/svg/octicon-stopwatch.svg +++ b/public/assets/img/svg/octicon-stopwatch.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-strikethrough.svg b/public/assets/img/svg/octicon-strikethrough.svg index d75258995bf..1a9c4a0d3ee 100644 --- a/public/assets/img/svg/octicon-strikethrough.svg +++ b/public/assets/img/svg/octicon-strikethrough.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sun.svg b/public/assets/img/svg/octicon-sun.svg index 1abeab2d1c6..07756fa4386 100644 --- a/public/assets/img/svg/octicon-sun.svg +++ b/public/assets/img/svg/octicon-sun.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-sync.svg b/public/assets/img/svg/octicon-sync.svg index 146e48f379a..30917e16b5b 100644 --- a/public/assets/img/svg/octicon-sync.svg +++ b/public/assets/img/svg/octicon-sync.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tab-external.svg b/public/assets/img/svg/octicon-tab-external.svg index b86888e6873..e1a979df1cc 100644 --- a/public/assets/img/svg/octicon-tab-external.svg +++ b/public/assets/img/svg/octicon-tab-external.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tag.svg b/public/assets/img/svg/octicon-tag.svg index 35c4f2e8174..0de0f6b4fe0 100644 --- a/public/assets/img/svg/octicon-tag.svg +++ b/public/assets/img/svg/octicon-tag.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tasklist.svg b/public/assets/img/svg/octicon-tasklist.svg index a568cc8c04a..b1067aefc32 100644 --- a/public/assets/img/svg/octicon-tasklist.svg +++ b/public/assets/img/svg/octicon-tasklist.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-telescope-fill.svg b/public/assets/img/svg/octicon-telescope-fill.svg index c1961c33235..4debe301d6f 100644 --- a/public/assets/img/svg/octicon-telescope-fill.svg +++ b/public/assets/img/svg/octicon-telescope-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-telescope.svg b/public/assets/img/svg/octicon-telescope.svg index ccac95cd594..17e79115ff2 100644 --- a/public/assets/img/svg/octicon-telescope.svg +++ b/public/assets/img/svg/octicon-telescope.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-terminal.svg b/public/assets/img/svg/octicon-terminal.svg index 4744caec0af..e6349af701b 100644 --- a/public/assets/img/svg/octicon-terminal.svg +++ b/public/assets/img/svg/octicon-terminal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-three-bars.svg b/public/assets/img/svg/octicon-three-bars.svg index 2b6fc0abedb..cf97b03044c 100644 --- a/public/assets/img/svg/octicon-three-bars.svg +++ b/public/assets/img/svg/octicon-three-bars.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-thumbsdown.svg b/public/assets/img/svg/octicon-thumbsdown.svg index 1a22693ea0d..f64457ec516 100644 --- a/public/assets/img/svg/octicon-thumbsdown.svg +++ b/public/assets/img/svg/octicon-thumbsdown.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-thumbsup.svg b/public/assets/img/svg/octicon-thumbsup.svg index ed38245f05a..1afc4ba99b2 100644 --- a/public/assets/img/svg/octicon-thumbsup.svg +++ b/public/assets/img/svg/octicon-thumbsup.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tools.svg b/public/assets/img/svg/octicon-tools.svg index 8b051eb3fd0..851c44f2eaf 100644 --- a/public/assets/img/svg/octicon-tools.svg +++ b/public/assets/img/svg/octicon-tools.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tracked-by-closed-completed.svg b/public/assets/img/svg/octicon-tracked-by-closed-completed.svg index 7a3af6dee2b..c906f0eb6c5 100644 --- a/public/assets/img/svg/octicon-tracked-by-closed-completed.svg +++ b/public/assets/img/svg/octicon-tracked-by-closed-completed.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-tracked-by-closed-not-planned.svg b/public/assets/img/svg/octicon-tracked-by-closed-not-planned.svg index bbeb817b822..f7383984287 100644 --- a/public/assets/img/svg/octicon-tracked-by-closed-not-planned.svg +++ b/public/assets/img/svg/octicon-tracked-by-closed-not-planned.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-trash.svg b/public/assets/img/svg/octicon-trash.svg index d0c0a5f712b..b52a439af77 100644 --- a/public/assets/img/svg/octicon-trash.svg +++ b/public/assets/img/svg/octicon-trash.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-triangle-down.svg b/public/assets/img/svg/octicon-triangle-down.svg index fd1dce1fe82..e8034484fcb 100644 --- a/public/assets/img/svg/octicon-triangle-down.svg +++ b/public/assets/img/svg/octicon-triangle-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-triangle-left.svg b/public/assets/img/svg/octicon-triangle-left.svg index 66343551a92..fde4d16bad4 100644 --- a/public/assets/img/svg/octicon-triangle-left.svg +++ b/public/assets/img/svg/octicon-triangle-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-triangle-right.svg b/public/assets/img/svg/octicon-triangle-right.svg index 7b39a67e6e6..48a009760ee 100644 --- a/public/assets/img/svg/octicon-triangle-right.svg +++ b/public/assets/img/svg/octicon-triangle-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-triangle-up.svg b/public/assets/img/svg/octicon-triangle-up.svg index f4b386b1756..1982cc84c48 100644 --- a/public/assets/img/svg/octicon-triangle-up.svg +++ b/public/assets/img/svg/octicon-triangle-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-trophy.svg b/public/assets/img/svg/octicon-trophy.svg index 0f1328e2e0e..62c37e70d41 100644 --- a/public/assets/img/svg/octicon-trophy.svg +++ b/public/assets/img/svg/octicon-trophy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-typography.svg b/public/assets/img/svg/octicon-typography.svg index 0b2088ed2fb..3ed9d8ff68b 100644 --- a/public/assets/img/svg/octicon-typography.svg +++ b/public/assets/img/svg/octicon-typography.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-undo.svg b/public/assets/img/svg/octicon-undo.svg index 53ac6464147..7dd709241ef 100644 --- a/public/assets/img/svg/octicon-undo.svg +++ b/public/assets/img/svg/octicon-undo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unfold.svg b/public/assets/img/svg/octicon-unfold.svg index ff4a0dd56a8..e383765b0c1 100644 --- a/public/assets/img/svg/octicon-unfold.svg +++ b/public/assets/img/svg/octicon-unfold.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unlink.svg b/public/assets/img/svg/octicon-unlink.svg index 0f77b14734c..a585e8ba909 100644 --- a/public/assets/img/svg/octicon-unlink.svg +++ b/public/assets/img/svg/octicon-unlink.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unlock.svg b/public/assets/img/svg/octicon-unlock.svg index b0739c67035..efeb0ef3838 100644 --- a/public/assets/img/svg/octicon-unlock.svg +++ b/public/assets/img/svg/octicon-unlock.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unmute.svg b/public/assets/img/svg/octicon-unmute.svg index 79234174e97..f471217b63f 100644 --- a/public/assets/img/svg/octicon-unmute.svg +++ b/public/assets/img/svg/octicon-unmute.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unread.svg b/public/assets/img/svg/octicon-unread.svg index 9652a18ff01..f2c41413b12 100644 --- a/public/assets/img/svg/octicon-unread.svg +++ b/public/assets/img/svg/octicon-unread.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-unverified.svg b/public/assets/img/svg/octicon-unverified.svg index c4bbddfab0b..5833f2316ac 100644 --- a/public/assets/img/svg/octicon-unverified.svg +++ b/public/assets/img/svg/octicon-unverified.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-upload.svg b/public/assets/img/svg/octicon-upload.svg index 16e3f04222f..c61e952511e 100644 --- a/public/assets/img/svg/octicon-upload.svg +++ b/public/assets/img/svg/octicon-upload.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-verified.svg b/public/assets/img/svg/octicon-verified.svg index a6de1a30d69..57fc6a0eab6 100644 --- a/public/assets/img/svg/octicon-verified.svg +++ b/public/assets/img/svg/octicon-verified.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-versions.svg b/public/assets/img/svg/octicon-versions.svg index b37de6aebee..7f5428055b4 100644 --- a/public/assets/img/svg/octicon-versions.svg +++ b/public/assets/img/svg/octicon-versions.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-video.svg b/public/assets/img/svg/octicon-video.svg index 95d92d87a63..600a42bbea1 100644 --- a/public/assets/img/svg/octicon-video.svg +++ b/public/assets/img/svg/octicon-video.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-webhook.svg b/public/assets/img/svg/octicon-webhook.svg index afb2e08209a..cce7537fb8b 100644 --- a/public/assets/img/svg/octicon-webhook.svg +++ b/public/assets/img/svg/octicon-webhook.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-x-circle-fill.svg b/public/assets/img/svg/octicon-x-circle-fill.svg index eafea6df78a..c0a63078849 100644 --- a/public/assets/img/svg/octicon-x-circle-fill.svg +++ b/public/assets/img/svg/octicon-x-circle-fill.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-x-circle.svg b/public/assets/img/svg/octicon-x-circle.svg index 26cce460d5c..94dc6b92817 100644 --- a/public/assets/img/svg/octicon-x-circle.svg +++ b/public/assets/img/svg/octicon-x-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-x.svg b/public/assets/img/svg/octicon-x.svg index de54c0928e7..2d78857db26 100644 --- a/public/assets/img/svg/octicon-x.svg +++ b/public/assets/img/svg/octicon-x.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-zap.svg b/public/assets/img/svg/octicon-zap.svg index f711e01eb14..a693cb6d958 100644 --- a/public/assets/img/svg/octicon-zap.svg +++ b/public/assets/img/svg/octicon-zap.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-zoom-in.svg b/public/assets/img/svg/octicon-zoom-in.svg index 88540b57668..1713ae7df11 100644 --- a/public/assets/img/svg/octicon-zoom-in.svg +++ b/public/assets/img/svg/octicon-zoom-in.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/img/svg/octicon-zoom-out.svg b/public/assets/img/svg/octicon-zoom-out.svg index d03925e0df8..9d682db55f3 100644 --- a/public/assets/img/svg/octicon-zoom-out.svg +++ b/public/assets/img/svg/octicon-zoom-out.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0c7e782a97e..bb768d5cb12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ description = "" authors = [] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.10" [tool.poetry.group.dev.dependencies] -djlint = "1.32.1" -yamllint = "1.32.0" +djlint = "1.34.1" +yamllint = "1.35.1" [tool.djlint] profile="golang" diff --git a/routers/api/actions/artifact.pb.go b/routers/api/actions/artifact.pb.go new file mode 100644 index 00000000000..590eda9fb9a --- /dev/null +++ b/routers/api/actions/artifact.pb.go @@ -0,0 +1,1058 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc v4.25.2 +// source: artifact.proto + +package actions + +import ( + reflect "reflect" + sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CreateArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + Version int32 `protobuf:"varint,5,opt,name=version,proto3" json:"version,omitempty"` +} + +func (x *CreateArtifactRequest) Reset() { + *x = CreateArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactRequest) ProtoMessage() {} + +func (x *CreateArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateArtifactRequest.ProtoReflect.Descriptor instead. +func (*CreateArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *CreateArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *CreateArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateArtifactRequest) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *CreateArtifactRequest) GetVersion() int32 { + if x != nil { + return x.Version + } + return 0 +} + +type CreateArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + SignedUploadUrl string `protobuf:"bytes,2,opt,name=signed_upload_url,json=signedUploadUrl,proto3" json:"signed_upload_url,omitempty"` +} + +func (x *CreateArtifactResponse) Reset() { + *x = CreateArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateArtifactResponse) ProtoMessage() {} + +func (x *CreateArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateArtifactResponse.ProtoReflect.Descriptor instead. +func (*CreateArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *CreateArtifactResponse) GetSignedUploadUrl() string { + if x != nil { + return x.SignedUploadUrl + } + return "" +} + +type FinalizeArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Hash *wrapperspb.StringValue `protobuf:"bytes,5,opt,name=hash,proto3" json:"hash,omitempty"` +} + +func (x *FinalizeArtifactRequest) Reset() { + *x = FinalizeArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeArtifactRequest) ProtoMessage() {} + +func (x *FinalizeArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeArtifactRequest.ProtoReflect.Descriptor instead. +func (*FinalizeArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{2} +} + +func (x *FinalizeArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *FinalizeArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *FinalizeArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FinalizeArtifactRequest) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FinalizeArtifactRequest) GetHash() *wrapperspb.StringValue { + if x != nil { + return x.Hash + } + return nil +} + +type FinalizeArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` +} + +func (x *FinalizeArtifactResponse) Reset() { + *x = FinalizeArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *FinalizeArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FinalizeArtifactResponse) ProtoMessage() {} + +func (x *FinalizeArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FinalizeArtifactResponse.ProtoReflect.Descriptor instead. +func (*FinalizeArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{3} +} + +func (x *FinalizeArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *FinalizeArtifactResponse) GetArtifactId() int64 { + if x != nil { + return x.ArtifactId + } + return 0 +} + +type ListArtifactsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + NameFilter *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=name_filter,json=nameFilter,proto3" json:"name_filter,omitempty"` + IdFilter *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=id_filter,json=idFilter,proto3" json:"id_filter,omitempty"` +} + +func (x *ListArtifactsRequest) Reset() { + *x = ListArtifactsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsRequest) ProtoMessage() {} + +func (x *ListArtifactsRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsRequest.ProtoReflect.Descriptor instead. +func (*ListArtifactsRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{4} +} + +func (x *ListArtifactsRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *ListArtifactsRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *ListArtifactsRequest) GetNameFilter() *wrapperspb.StringValue { + if x != nil { + return x.NameFilter + } + return nil +} + +func (x *ListArtifactsRequest) GetIdFilter() *wrapperspb.Int64Value { + if x != nil { + return x.IdFilter + } + return nil +} + +type ListArtifactsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Artifacts []*ListArtifactsResponse_MonolithArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"` +} + +func (x *ListArtifactsResponse) Reset() { + *x = ListArtifactsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsResponse) ProtoMessage() {} + +func (x *ListArtifactsResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsResponse.ProtoReflect.Descriptor instead. +func (*ListArtifactsResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{5} +} + +func (x *ListArtifactsResponse) GetArtifacts() []*ListArtifactsResponse_MonolithArtifact { + if x != nil { + return x.Artifacts + } + return nil +} + +type ListArtifactsResponse_MonolithArtifact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + DatabaseId int64 `protobuf:"varint,3,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Size int64 `protobuf:"varint,5,opt,name=size,proto3" json:"size,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` +} + +func (x *ListArtifactsResponse_MonolithArtifact) Reset() { + *x = ListArtifactsResponse_MonolithArtifact{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListArtifactsResponse_MonolithArtifact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListArtifactsResponse_MonolithArtifact) ProtoMessage() {} + +func (x *ListArtifactsResponse_MonolithArtifact) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListArtifactsResponse_MonolithArtifact.ProtoReflect.Descriptor instead. +func (*ListArtifactsResponse_MonolithArtifact) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{6} +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetDatabaseId() int64 { + if x != nil { + return x.DatabaseId + } + return 0 +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *ListArtifactsResponse_MonolithArtifact) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +type GetSignedArtifactURLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *GetSignedArtifactURLRequest) Reset() { + *x = GetSignedArtifactURLRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSignedArtifactURLRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSignedArtifactURLRequest) ProtoMessage() {} + +func (x *GetSignedArtifactURLRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSignedArtifactURLRequest.ProtoReflect.Descriptor instead. +func (*GetSignedArtifactURLRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{7} +} + +func (x *GetSignedArtifactURLRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *GetSignedArtifactURLRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *GetSignedArtifactURLRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type GetSignedArtifactURLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SignedUrl string `protobuf:"bytes,1,opt,name=signed_url,json=signedUrl,proto3" json:"signed_url,omitempty"` +} + +func (x *GetSignedArtifactURLResponse) Reset() { + *x = GetSignedArtifactURLResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetSignedArtifactURLResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSignedArtifactURLResponse) ProtoMessage() {} + +func (x *GetSignedArtifactURLResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSignedArtifactURLResponse.ProtoReflect.Descriptor instead. +func (*GetSignedArtifactURLResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{8} +} + +func (x *GetSignedArtifactURLResponse) GetSignedUrl() string { + if x != nil { + return x.SignedUrl + } + return "" +} + +type DeleteArtifactRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *DeleteArtifactRequest) Reset() { + *x = DeleteArtifactRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteArtifactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactRequest) ProtoMessage() {} + +func (x *DeleteArtifactRequest) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteArtifactRequest.ProtoReflect.Descriptor instead. +func (*DeleteArtifactRequest) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteArtifactRequest) GetWorkflowRunBackendId() string { + if x != nil { + return x.WorkflowRunBackendId + } + return "" +} + +func (x *DeleteArtifactRequest) GetWorkflowJobRunBackendId() string { + if x != nil { + return x.WorkflowJobRunBackendId + } + return "" +} + +func (x *DeleteArtifactRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DeleteArtifactResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` +} + +func (x *DeleteArtifactResponse) Reset() { + *x = DeleteArtifactResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_artifact_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteArtifactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteArtifactResponse) ProtoMessage() {} + +func (x *DeleteArtifactResponse) ProtoReflect() protoreflect.Message { + mi := &file_artifact_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteArtifactResponse.ProtoReflect.Descriptor instead. +func (*DeleteArtifactResponse) Descriptor() ([]byte, []int) { + return file_artifact_proto_rawDescGZIP(), []int{10} +} + +func (x *DeleteArtifactResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *DeleteArtifactResponse) GetArtifactId() int64 { + if x != nil { + return x.ArtifactId + } + return 0 +} + +var File_artifact_proto protoreflect.FileDescriptor + +var file_artifact_proto_rawDesc = []byte{ + 0x0a, 0x0e, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x1a, + 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2f, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0xf5, 0x01, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, + 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, + 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, + 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x54, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, + 0x6f, 0x6b, 0x12, 0x2a, 0x0a, 0x11, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, 0x70, 0x6c, + 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, + 0x69, 0x67, 0x6e, 0x65, 0x64, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xe8, + 0x01, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, + 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, + 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, + 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x30, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x22, 0x4b, 0x0a, 0x18, 0x46, 0x69, 0x6e, + 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, + 0x66, 0x61, 0x63, 0x74, 0x49, 0x64, 0x22, 0x84, 0x02, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x41, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, + 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3d, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x66, 0x69, 0x6c, + 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, + 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x46, 0x69, 0x6c, + 0x74, 0x65, 0x72, 0x12, 0x38, 0x0a, 0x09, 0x69, 0x64, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x36, 0x34, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x08, 0x69, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x22, 0x7c, 0x0a, + 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, + 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x45, 0x2e, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x73, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, + 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x52, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x22, 0xa1, 0x02, 0x0a, 0x26, + 0x4c, 0x69, 0x73, 0x74, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x4d, 0x6f, 0x6e, 0x6f, 0x6c, 0x69, 0x74, 0x68, 0x41, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, + 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, + 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, + 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, + 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, + 0x73, 0x69, 0x7a, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, + 0xa6, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, + 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, 0x6e, 0x5f, + 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, + 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, 0x6f, 0x72, + 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, 0x6b, 0x65, + 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3d, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x53, + 0x69, 0x67, 0x6e, 0x65, 0x64, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x52, 0x4c, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, + 0x65, 0x64, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x69, + 0x67, 0x6e, 0x65, 0x64, 0x55, 0x72, 0x6c, 0x22, 0xa0, 0x01, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x35, 0x0a, 0x17, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x75, + 0x6e, 0x5f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x52, 0x75, 0x6e, 0x42, + 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x77, 0x6f, 0x72, 0x6b, + 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x6a, 0x6f, 0x62, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x62, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x77, + 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x4a, 0x6f, 0x62, 0x52, 0x75, 0x6e, 0x42, 0x61, 0x63, + 0x6b, 0x65, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x49, 0x0a, 0x16, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x49, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_artifact_proto_rawDescOnce sync.Once + file_artifact_proto_rawDescData = file_artifact_proto_rawDesc +) + +func file_artifact_proto_rawDescGZIP() []byte { + file_artifact_proto_rawDescOnce.Do(func() { + file_artifact_proto_rawDescData = protoimpl.X.CompressGZIP(file_artifact_proto_rawDescData) + }) + return file_artifact_proto_rawDescData +} + +var ( + file_artifact_proto_msgTypes = make([]protoimpl.MessageInfo, 11) + file_artifact_proto_goTypes = []interface{}{ + (*CreateArtifactRequest)(nil), // 0: github.actions.results.api.v1.CreateArtifactRequest + (*CreateArtifactResponse)(nil), // 1: github.actions.results.api.v1.CreateArtifactResponse + (*FinalizeArtifactRequest)(nil), // 2: github.actions.results.api.v1.FinalizeArtifactRequest + (*FinalizeArtifactResponse)(nil), // 3: github.actions.results.api.v1.FinalizeArtifactResponse + (*ListArtifactsRequest)(nil), // 4: github.actions.results.api.v1.ListArtifactsRequest + (*ListArtifactsResponse)(nil), // 5: github.actions.results.api.v1.ListArtifactsResponse + (*ListArtifactsResponse_MonolithArtifact)(nil), // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + (*GetSignedArtifactURLRequest)(nil), // 7: github.actions.results.api.v1.GetSignedArtifactURLRequest + (*GetSignedArtifactURLResponse)(nil), // 8: github.actions.results.api.v1.GetSignedArtifactURLResponse + (*DeleteArtifactRequest)(nil), // 9: github.actions.results.api.v1.DeleteArtifactRequest + (*DeleteArtifactResponse)(nil), // 10: github.actions.results.api.v1.DeleteArtifactResponse + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*wrapperspb.StringValue)(nil), // 12: google.protobuf.StringValue + (*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value + } +) + +var file_artifact_proto_depIdxs = []int32{ + 11, // 0: github.actions.results.api.v1.CreateArtifactRequest.expires_at:type_name -> google.protobuf.Timestamp + 12, // 1: github.actions.results.api.v1.FinalizeArtifactRequest.hash:type_name -> google.protobuf.StringValue + 12, // 2: github.actions.results.api.v1.ListArtifactsRequest.name_filter:type_name -> google.protobuf.StringValue + 13, // 3: github.actions.results.api.v1.ListArtifactsRequest.id_filter:type_name -> google.protobuf.Int64Value + 6, // 4: github.actions.results.api.v1.ListArtifactsResponse.artifacts:type_name -> github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact + 11, // 5: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact.created_at:type_name -> google.protobuf.Timestamp + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_artifact_proto_init() } +func file_artifact_proto_init() { + if File_artifact_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_artifact_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*FinalizeArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ListArtifactsResponse_MonolithArtifact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSignedArtifactURLRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetSignedArtifactURLResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteArtifactRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_artifact_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeleteArtifactResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_artifact_proto_rawDesc, + NumEnums: 0, + NumMessages: 11, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_artifact_proto_goTypes, + DependencyIndexes: file_artifact_proto_depIdxs, + MessageInfos: file_artifact_proto_msgTypes, + }.Build() + File_artifact_proto = out.File + file_artifact_proto_rawDesc = nil + file_artifact_proto_goTypes = nil + file_artifact_proto_depIdxs = nil +} diff --git a/routers/api/actions/artifact.proto b/routers/api/actions/artifact.proto new file mode 100644 index 00000000000..c68e5d030d0 --- /dev/null +++ b/routers/api/actions/artifact.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +package github.actions.results.api.v1; + +message CreateArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; + google.protobuf.Timestamp expires_at = 4; + int32 version = 5; +} + +message CreateArtifactResponse { + bool ok = 1; + string signed_upload_url = 2; +} + +message FinalizeArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; + int64 size = 4; + google.protobuf.StringValue hash = 5; +} + +message FinalizeArtifactResponse { + bool ok = 1; + int64 artifact_id = 2; +} + +message ListArtifactsRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + google.protobuf.StringValue name_filter = 3; + google.protobuf.Int64Value id_filter = 4; +} + +message ListArtifactsResponse { + repeated ListArtifactsResponse_MonolithArtifact artifacts = 1; +} + +message ListArtifactsResponse_MonolithArtifact { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + int64 database_id = 3; + string name = 4; + int64 size = 5; + google.protobuf.Timestamp created_at = 6; +} + +message GetSignedArtifactURLRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; +} + +message GetSignedArtifactURLResponse { + string signed_url = 1; +} + +message DeleteArtifactRequest { + string workflow_run_backend_id = 1; + string workflow_job_run_backend_id = 2; + string name = 3; +} + +message DeleteArtifactResponse { + bool ok = 1; + int64 artifact_id = 2; +} diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index c45dc667afc..d530e9cee56 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -70,7 +70,7 @@ import ( "strings" "code.gitea.io/gitea/models/actions" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -78,6 +78,8 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" web_types "code.gitea.io/gitea/modules/web/types" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" ) const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" @@ -137,12 +139,33 @@ func ArtifactContexter() func(next http.Handler) http.Handler { return } - authToken := strings.TrimPrefix(authHeader, "Bearer ") - task, err := actions.GetRunningTaskByToken(req.Context(), authToken) - if err != nil { - log.Error("Error runner api getting task: %v", err) - ctx.Error(http.StatusInternalServerError, "Error runner api getting task") - return + // New act_runner uses jwt to authenticate + tID, err := actions_service.ParseAuthorizationToken(req) + + var task *actions.ActionTask + if err == nil { + + task, err = actions.GetTaskByID(req.Context(), tID) + if err != nil { + log.Error("Error runner api getting task by ID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") + return + } + if task.Status != actions.StatusRunning { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + } else { + // Old act_runner uses GITEA_TOKEN to authenticate + authToken := strings.TrimPrefix(authHeader, "Bearer ") + + task, err = actions.GetRunningTaskByToken(req.Context(), authToken) + if err != nil { + log.Error("Error runner api getting task: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task") + return + } } if err := task.LoadJob(req.Context()); err != nil { @@ -257,8 +280,11 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { return } - // update artifact size if zero - if artifact.FileSize == 0 || artifact.FileCompressedSize == 0 { + // update artifact size if zero or not match, over write artifact size + if artifact.FileSize == 0 || + artifact.FileCompressedSize == 0 || + artifact.FileSize != fileRealTotalSize || + artifact.FileCompressedSize != chunksTotalSize { artifact.FileSize = fileRealTotalSize artifact.FileCompressedSize = chunksTotalSize artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") @@ -267,6 +293,8 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { ctx.Error(http.StatusInternalServerError, "Error update artifact") return } + log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d", + artifact.ID, artifact.FileSize, artifact.FileCompressedSize) } ctx.JSON(http.StatusOK, map[string]string{ @@ -314,7 +342,7 @@ func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { return } - artifacts, err := actions.ListArtifactsByRunID(ctx, runID) + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) if err != nil { log.Error("Error getting artifacts: %v", err) ctx.Error(http.StatusInternalServerError, err.Error()) @@ -376,7 +404,10 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { return } - artifacts, err := actions.ListArtifactsByRunIDAndArtifactName(ctx, runID, itemPath) + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ + RunID: runID, + ArtifactName: itemPath, + }) if err != nil { log.Error("Error getting artifacts: %v", err) ctx.Error(http.StatusInternalServerError, err.Error()) @@ -396,7 +427,19 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { var items []downloadArtifactResponseItem for _, artifact := range artifacts { - downloadURL := ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download") + var downloadURL string + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName) + if err != nil && !errors.Is(err, storage.ErrURLNotSupported) { + log.Error("Error getting serve direct url: %v", err) + } + if u != nil { + downloadURL = u.String() + } + } + if downloadURL == "" { + downloadURL = ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download") + } item := downloadArtifactResponseItem{ Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), ItemType: "file", @@ -419,15 +462,15 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { } artifactID := ctx.ParamsInt64("artifact_id") - artifact, err := actions.GetArtifactByID(ctx, artifactID) - if errors.Is(err, util.ErrNotExist) { - log.Error("Error getting artifact: %v", err) - ctx.Error(http.StatusNotFound, err.Error()) - return - } else if err != nil { + artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID) + if err != nil { log.Error("Error getting artifact: %v", err) ctx.Error(http.StatusInternalServerError, err.Error()) return + } else if !exist { + log.Error("artifact with ID %d does not exist", artifactID) + ctx.Error(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID)) + return } if artifact.RunID != runID { log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index 458d671cff6..3a81724b3ad 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -5,50 +5,63 @@ package actions import ( "crypto/md5" + "crypto/sha256" "encoding/base64" + "encoding/hex" + "errors" "fmt" + "hash" "io" "path/filepath" "sort" + "strings" "time" "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" ) -func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, +func saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext, artifact *actions.ActionArtifact, - contentSize, runID int64, + contentSize, runID, start, end, length int64, checkMd5 bool, ) (int64, error) { - // parse content-range header, format: bytes 0-1023/146515 - contentRange := ctx.Req.Header.Get("Content-Range") - start, end, length := int64(0), int64(0), int64(0) - if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil { - return -1, fmt.Errorf("parse content range error: %v", err) - } // build chunk store path - storagePath := fmt.Sprintf("tmp%d/%d-%d-%d.chunk", runID, artifact.ID, start, end) - // use io.TeeReader to avoid reading all body to md5 sum. - // it writes data to hasher after reading end - // if hash is not matched, delete the read-end result - hasher := md5.New() - r := io.TeeReader(ctx.Req.Body, hasher) + storagePath := fmt.Sprintf("tmp%d/%d-%d-%d-%d.chunk", runID, runID, artifact.ID, start, end) + var r io.Reader = ctx.Req.Body + var hasher hash.Hash + if checkMd5 { + // use io.TeeReader to avoid reading all body to md5 sum. + // it writes data to hasher after reading end + // if hash is not matched, delete the read-end result + hasher = md5.New() + r = io.TeeReader(r, hasher) + } // save chunk to storage writtenSize, err := st.Save(storagePath, r, -1) if err != nil { return -1, fmt.Errorf("save chunk to storage error: %v", err) } - // check md5 - reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header) - chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) - log.Info("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String) - // if md5 not match, delete the chunk - if reqMd5String != chunkMd5String || writtenSize != contentSize { + var checkErr error + if checkMd5 { + // check md5 + reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header) + chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + log.Info("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String) + // if md5 not match, delete the chunk + if reqMd5String != chunkMd5String { + checkErr = fmt.Errorf("md5 not match") + } + } + if writtenSize != contentSize { + checkErr = errors.Join(checkErr, fmt.Errorf("contentSize not match body size")) + } + if checkErr != nil { if err := st.Delete(storagePath); err != nil { log.Error("Error deleting chunk: %s, %v", storagePath, err) } - return -1, fmt.Errorf("md5 not match") + return -1, checkErr } log.Info("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d", storagePath, contentSize, artifact.ID, start, end) @@ -56,7 +69,30 @@ func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, return length, nil } +func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + contentSize, runID int64, +) (int64, error) { + // parse content-range header, format: bytes 0-1023/146515 + contentRange := ctx.Req.Header.Get("Content-Range") + start, end, length := int64(0), int64(0), int64(0) + if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &length); err != nil { + log.Warn("parse content range error: %v, content-range: %s", err, contentRange) + return -1, fmt.Errorf("parse content range error: %v", err) + } + return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, length, true) +} + +func appendUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + start, contentSize, runID int64, +) (int64, error) { + end := start + contentSize - 1 + return saveUploadChunkBase(st, ctx, artifact, contentSize, runID, start, end, contentSize, false) +} + type chunkFileItem struct { + RunID int64 ArtifactID int64 Start int64 End int64 @@ -66,9 +102,12 @@ type chunkFileItem struct { func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chunkFileItem, error) { storageDir := fmt.Sprintf("tmp%d", runID) var chunks []*chunkFileItem - if err := st.IterateObjects(storageDir, func(path string, obj storage.Object) error { - item := chunkFileItem{Path: path} - if _, err := fmt.Sscanf(path, filepath.Join(storageDir, "%d-%d-%d.chunk"), &item.ArtifactID, &item.Start, &item.End); err != nil { + if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error { + baseName := filepath.Base(fpath) + // when read chunks from storage, it only contains storage dir and basename, + // no matter the subdirectory setting in storage config + item := chunkFileItem{Path: storageDir + "/" + baseName} + if _, err := fmt.Sscanf(baseName, "%d-%d-%d-%d.chunk", &item.RunID, &item.ArtifactID, &item.Start, &item.End); err != nil { return fmt.Errorf("parse content range error: %v", err) } chunks = append(chunks, &item) @@ -86,7 +125,10 @@ func listChunksByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chun func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int64, artifactName string) error { // read all db artifacts by name - artifacts, err := actions.ListArtifactsByRunIDAndName(ctx, runID, artifactName) + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ + RunID: runID, + ArtifactName: artifactName, + }) if err != nil { return err } @@ -102,14 +144,14 @@ func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID int log.Debug("artifact %d chunks not found", art.ID) continue } - if err := mergeChunksForArtifact(ctx, chunks, st, art); err != nil { + if err := mergeChunksForArtifact(ctx, chunks, st, art, ""); err != nil { return err } } return nil } -func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st storage.ObjectStorage, artifact *actions.ActionArtifact) error { +func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st storage.ObjectStorage, artifact *actions.ActionArtifact, checksum string) error { sort.Slice(chunks, func(i, j int) bool { return chunks[i].Start < chunks[j].Start }) @@ -148,6 +190,14 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st readers = append(readers, readCloser) } mergedReader := io.MultiReader(readers...) + shaPrefix := "sha256:" + var hash hash.Hash + if strings.HasPrefix(checksum, shaPrefix) { + hash = sha256.New() + } + if hash != nil { + mergedReader = io.TeeReader(mergedReader, hash) + } // if chunk is gzip, use gz as extension // download-artifact action will use content-encoding header to decide if it should decompress the file @@ -176,8 +226,23 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st } }() + if hash != nil { + rawChecksum := hash.Sum(nil) + actualChecksum := hex.EncodeToString(rawChecksum) + if !strings.HasSuffix(checksum, actualChecksum) { + return fmt.Errorf("update artifact error checksum is invalid") + } + } + // save storage path to artifact - log.Debug("[artifact] merge chunks to artifact: %d, %s", artifact.ID, storagePath) + log.Debug("[artifact] merge chunks to artifact: %d, %s, old:%s", artifact.ID, storagePath, artifact.StoragePath) + // if artifact is already uploaded, delete the old file + if artifact.StoragePath != "" { + if err := st.Delete(artifact.StoragePath); err != nil { + log.Warn("Error deleting old artifact: %s, %v", artifact.StoragePath, err) + } + } + artifact.StoragePath = storagePath artifact.Status = int64(actions.ArtifactStatusUploadConfirmed) if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { diff --git a/routers/api/actions/artifacts_utils.go b/routers/api/actions/artifacts_utils.go index 4c939348622..aaf89ef40e6 100644 --- a/routers/api/actions/artifacts_utils.go +++ b/routers/api/actions/artifacts_utils.go @@ -43,6 +43,17 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) { return task, runID, true } +func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { + task := ctx.ActionTask + runID, err := strconv.ParseInt(rawRunID, 10, 64) + if err != nil || task.Job.RunID != runID { + log.Error("Error runID not match") + ctx.Error(http.StatusBadRequest, "run-id does not match") + return nil, 0, false + } + return task, runID, true +} + func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool { paramHash := ctx.Params("artifact_hash") // use artifact name to create upload url @@ -58,7 +69,8 @@ func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool { func parseArtifactItemPath(ctx *ArtifactContext) (string, string, bool) { // itemPath is generated from upload-artifact action // it's formatted as {artifact_name}/{artfict_path_in_runner} - itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) + // act_runner in host mode on Windows, itemPath is joined by Windows slash '\' + itemPath := util.PathJoinRelX(ctx.Req.URL.Query().Get("itemPath")) artifactName := strings.Split(itemPath, "/")[0] artifactPath := strings.TrimPrefix(itemPath, artifactName+"/") if !validateArtifactHash(ctx, artifactName) { diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go new file mode 100644 index 00000000000..8300989c75c --- /dev/null +++ b/routers/api/actions/artifactsv4.go @@ -0,0 +1,512 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +// GitHub Actions Artifacts V4 API Simple Description +// +// 1. Upload artifact +// 1.1. CreateArtifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact +// Request: +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test", +// "version": 4 +// } +// Response: +// { +// "ok": true, +// "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75" +// } +// 1.2. Upload Zip Content to Blobstorage (unauthenticated request) +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block +// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock +// 1.4. Unknown xml payload to Blobstorage (unauthenticated request), ignored for now +// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList +// 1.5. FinalizeArtifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test", +// "size": "2097", +// "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4" +// } +// Response +// { +// "ok": true, +// "artifactId": "4" +// } +// 2. Download artifact +// 2.1. ListArtifacts and optionally filter by artifact exact name or id +// Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name_filter": "test" +// } +// Response +// { +// "artifacts": [ +// { +// "workflowRunBackendId": "21", +// "workflowJobRunBackendId": "49", +// "databaseId": "4", +// "name": "test", +// "size": "2093", +// "createdAt": "2024-01-23T00:13:28Z" +// } +// ] +// } +// 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact +// Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL +// Request +// { +// "workflow_run_backend_id": "21", +// "workflow_job_run_backend_id": "49", +// "name": "test" +// } +// Response +// { +// "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76" +// } +// 2.3. Download Zip from Blobstorage (unauthenticated request) +// GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76 + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + + "google.golang.org/protobuf/encoding/protojson" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService" + ArtifactV4ContentEncoding = "application/zip" +) + +type artifactV4Routes struct { + prefix string + fs storage.ObjectStorage +} + +func ArtifactV4Contexter() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + base, baseCleanUp := context.NewBaseContext(resp, req) + defer baseCleanUp() + + ctx := &ArtifactContext{Base: base} + ctx.AppendContextValue(artifactContextKey, ctx) + + next.ServeHTTP(ctx.Resp, ctx.Req) + }) + } +} + +func ArtifactsV4Routes(prefix string) *web.Route { + m := web.NewRoute() + + r := artifactV4Routes{ + prefix: prefix, + fs: storage.ActionsArtifacts, + } + + m.Group("", func() { + m.Post("CreateArtifact", r.createArtifact) + m.Post("FinalizeArtifact", r.finalizeArtifact) + m.Post("ListArtifacts", r.listArtifacts) + m.Post("GetSignedArtifactURL", r.getSignedArtifactURL) + m.Post("DeleteArtifact", r.deleteArtifact) + }, ArtifactContexter()) + m.Group("", func() { + m.Put("UploadArtifact", r.uploadArtifact) + m.Get("DownloadArtifact", r.downloadArtifact) + }, ArtifactV4Contexter()) + + return m +} + +func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID int64) []byte { + mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) + mac.Write([]byte(endp)) + mac.Write([]byte(expires)) + mac.Write([]byte(artifactName)) + mac.Write([]byte(fmt.Sprint(taskID))) + return mac.Sum(nil) +} + +func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string { + expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST") + uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") + + "/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID) + return uploadURL +} + +func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) { + rawTaskID := ctx.Req.URL.Query().Get("taskID") + sig := ctx.Req.URL.Query().Get("sig") + expires := ctx.Req.URL.Query().Get("expires") + artifactName := ctx.Req.URL.Query().Get("artifactName") + dsig, _ := base64.URLEncoding.DecodeString(sig) + taskID, _ := strconv.ParseInt(rawTaskID, 10, 64) + + expecedsig := r.buildSignature(endp, expires, artifactName, taskID) + if !hmac.Equal(dsig, expecedsig) { + log.Error("Error unauthorized") + ctx.Error(http.StatusUnauthorized, "Error unauthorized") + return nil, "", false + } + t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires) + if err != nil || t.Before(time.Now()) { + log.Error("Error link expired") + ctx.Error(http.StatusUnauthorized, "Error link expired") + return nil, "", false + } + task, err := actions.GetTaskByID(ctx, taskID) + if err != nil { + log.Error("Error runner api getting task by ID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") + return nil, "", false + } + if task.Status != actions.StatusRunning { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return nil, "", false + } + if err := task.LoadJob(ctx); err != nil { + log.Error("Error runner api getting job: %v", err) + ctx.Error(http.StatusInternalServerError, "Error runner api getting job") + return nil, "", false + } + return task, artifactName, true +} + +func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { + var art actions.ActionArtifact + has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ? AND content_encoding = ?", runID, name, name+".zip", ArtifactV4ContentEncoding).Get(&art) + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + return &art, nil +} + +func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool { + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return false + } + err = protojson.Unmarshal(body, req) + if err != nil { + log.Error("Error decode request body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error decode request body") + return false + } + return true +} + +func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) { + resp, err := protojson.Marshal(req) + if err != nil { + log.Error("Error encode response body: %v", err) + ctx.Error(http.StatusInternalServerError, "Error encode response body") + return + } + ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(resp) +} + +func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { + var req CreateArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifactName := req.Name + + rententionDays := setting.Actions.ArtifactRetentionDays + if req.ExpiresAt != nil { + rententionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24) + } + // create or get artifact with name and path + artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, artifactName+".zip", rententionDays) + if err != nil { + log.Error("Error create or get artifact: %v", err) + ctx.Error(http.StatusInternalServerError, "Error create or get artifact") + return + } + artifact.ContentEncoding = ArtifactV4ContentEncoding + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") + return + } + + respData := CreateArtifactResponse{ + Ok: true, + SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, ctx.ActionTask.ID), + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { + task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") + if !ok { + return + } + + comp := ctx.Req.URL.Query().Get("comp") + switch comp { + case "block", "appendBlock": + // get artifact by name + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + if comp == "block" { + artifact.FileSize = 0 + artifact.FileCompressedSize = 0 + } + + _, err = appendUploadChunk(r.fs, ctx, artifact, artifact.FileSize, ctx.Req.ContentLength, artifact.RunID) + if err != nil { + log.Error("Error runner api getting task: task is not running") + ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") + return + } + artifact.FileCompressedSize += ctx.Req.ContentLength + artifact.FileSize += ctx.Req.ContentLength + if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { + log.Error("Error UpdateArtifactByID: %v", err) + ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") + return + } + ctx.JSON(http.StatusCreated, "appended") + case "blocklist": + ctx.JSON(http.StatusCreated, "created") + } +} + +func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { + var req FinalizeArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, req.Name) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + chunkMap, err := listChunksByRunID(r.fs, runID) + if err != nil { + log.Error("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + chunks, ok := chunkMap[artifact.ID] + if !ok { + log.Error("Error merge chunks") + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + checksum := "" + if req.Hash != nil { + checksum = req.Hash.Value + } + if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, checksum); err != nil { + log.Error("Error merge chunks: %v", err) + ctx.Error(http.StatusInternalServerError, "Error merge chunks") + return + } + + respData := FinalizeArtifactResponse{ + Ok: true, + ArtifactId: artifact.ID, + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { + var req ListArtifactsRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) + if err != nil { + log.Error("Error getting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + if len(artifacts) == 0 { + log.Debug("[artifact] handleListArtifacts, no artifacts") + ctx.Error(http.StatusNotFound) + return + } + + list := []*ListArtifactsResponse_MonolithArtifact{} + + table := map[string]*ListArtifactsResponse_MonolithArtifact{} + for _, artifact := range artifacts { + if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value || artifact.ArtifactName+".zip" != artifact.ArtifactPath || artifact.ContentEncoding != ArtifactV4ContentEncoding { + table[artifact.ArtifactName] = nil + continue + } + + table[artifact.ArtifactName] = &ListArtifactsResponse_MonolithArtifact{ + Name: artifact.ArtifactName, + CreatedAt: timestamppb.New(artifact.CreatedUnix.AsTime()), + DatabaseId: artifact.ID, + WorkflowRunBackendId: req.WorkflowRunBackendId, + WorkflowJobRunBackendId: req.WorkflowJobRunBackendId, + Size: artifact.FileSize, + } + } + for _, artifact := range table { + if artifact != nil { + list = append(list, artifact) + } + } + + respData := ListArtifactsResponse{ + Artifacts: list, + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { + var req GetSignedArtifactURLRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + artifactName := req.Name + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + respData := GetSignedArtifactURLResponse{} + + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath) + if u != nil && err == nil { + respData.SignedUrl = u.String() + } + } + if respData.SignedUrl == "" { + respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, ctx.ActionTask.ID) + } + r.sendProtbufBody(ctx, &respData) +} + +func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { + task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + file, _ := r.fs.Open(artifact.StoragePath) + + _, _ = io.Copy(ctx.Resp, file) +} + +func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { + var req DeleteArtifactRequest + + if ok := r.parseProtbufBody(ctx, &req); !ok { + return + } + _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) + if !ok { + return + } + + // get artifact by name + artifact, err := r.getArtifactByName(ctx, runID, req.Name) + if err != nil { + log.Error("Error artifact not found: %v", err) + ctx.Error(http.StatusNotFound, "Error artifact not found") + return + } + + err = actions.SetArtifactNeedDelete(ctx, runID, req.Name) + if err != nil { + log.Error("Error deleting artifacts: %v", err) + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + respData := DeleteArtifactResponse{ + Ok: true, + ArtifactId: artifact.ID, + } + r.sendProtbufBody(ctx, &respData) +} diff --git a/routers/api/actions/ping/ping.go b/routers/api/actions/ping/ping.go index 55219fe12bc..828350407ac 100644 --- a/routers/api/actions/ping/ping.go +++ b/routers/api/actions/ping/ping.go @@ -12,7 +12,7 @@ import ( pingv1 "code.gitea.io/actions-proto-go/ping/v1" "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" - "github.com/bufbuild/connect-go" + "connectrpc.com/connect" ) func NewPingServiceHandler() (string, http.Handler) { @@ -21,9 +21,7 @@ func NewPingServiceHandler() (string, http.Handler) { var _ pingv1connect.PingServiceHandler = (*Service)(nil) -type Service struct { - pingv1connect.UnimplementedPingServiceHandler -} +type Service struct{} func (s *Service) Ping( ctx context.Context, diff --git a/routers/api/actions/ping/ping_test.go b/routers/api/actions/ping/ping_test.go index f39e94a1f30..098b003ea23 100644 --- a/routers/api/actions/ping/ping_test.go +++ b/routers/api/actions/ping/ping_test.go @@ -11,7 +11,7 @@ import ( pingv1 "code.gitea.io/actions-proto-go/ping/v1" "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" - "github.com/bufbuild/connect-go" + "connectrpc.com/connect" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go index ddc754dbc72..c2f4ade1742 100644 --- a/routers/api/actions/runner/interceptor.go +++ b/routers/api/actions/runner/interceptor.go @@ -15,7 +15,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" - "github.com/bufbuild/connect-go" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index cb206f56851..1d07be3aecd 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -16,7 +16,7 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" - "github.com/bufbuild/connect-go" + "connectrpc.com/connect" gouuid "github.com/google/uuid" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -32,9 +32,7 @@ func NewRunnerServiceHandler() (string, http.Handler) { var _ runnerv1connect.RunnerServiceClient = (*Service)(nil) -type Service struct { - runnerv1connect.UnimplementedRunnerServiceHandler -} +type Service struct{} // Register for new runner. func (s *Service) Register( @@ -47,11 +45,11 @@ func (s *Service) Register( runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token) if err != nil { - return nil, errors.New("runner token not found") + return nil, errors.New("runner registration token not found") } - if runnerToken.IsActive { - return nil, errors.New("runner token has already been activated") + if !runnerToken.IsActive { + return nil, errors.New("runner registration token has been invalidated, please use the latest one") } labels := req.Msg.Labels diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go index 24432ab6b20..ff6ec5bd54c 100644 --- a/routers/api/actions/runner/utils.go +++ b/routers/api/actions/runner/utils.go @@ -8,13 +8,13 @@ import ( "fmt" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" - secret_module "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/actions" @@ -31,14 +31,24 @@ func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return nil, false, nil } + secrets, err := secret_model.GetSecretsOfTask(ctx, t) + if err != nil { + return nil, false, fmt.Errorf("GetSecretsOfTask: %w", err) + } + + vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run) + if err != nil { + return nil, false, fmt.Errorf("GetVariablesOfRun: %w", err) + } + actions.CreateCommitStatus(ctx, t.Job) task := &runnerv1.Task{ Id: t.ID, WorkflowPayload: t.Job.WorkflowPayload, Context: generateTaskContext(t), - Secrets: getSecretsOfTask(ctx, t), - Vars: getVariablesOfTask(ctx, t), + Secrets: secrets, + Vars: vars, } if needs, err := findTaskNeeds(ctx, t); err != nil { @@ -54,65 +64,6 @@ func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return task, true, nil } -func getSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) map[string]string { - secrets := map[string]string{} - - secrets["GITHUB_TOKEN"] = task.Token - secrets["GITEA_TOKEN"] = task.Token - - if task.Job.Run.IsForkPullRequest && task.Job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget { - // ignore secrets for fork pull request, except GITHUB_TOKEN and GITEA_TOKEN which are automatically generated. - // for the tasks triggered by pull_request_target event, they could access the secrets because they will run in the context of the base branch - // see the documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target - return secrets - } - - ownerSecrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{OwnerID: task.Job.Run.Repo.OwnerID}) - if err != nil { - log.Error("find secrets of owner %v: %v", task.Job.Run.Repo.OwnerID, err) - // go on - } - repoSecrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{RepoID: task.Job.Run.RepoID}) - if err != nil { - log.Error("find secrets of repo %v: %v", task.Job.Run.RepoID, err) - // go on - } - - for _, secret := range append(ownerSecrets, repoSecrets...) { - if v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data); err != nil { - log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err) - // go on - } else { - secrets[secret.Name] = v - } - } - - return secrets -} - -func getVariablesOfTask(ctx context.Context, task *actions_model.ActionTask) map[string]string { - variables := map[string]string{} - - // Org / User level - ownerVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{OwnerID: task.Job.Run.Repo.OwnerID}) - if err != nil { - log.Error("find variables of org: %d, error: %v", task.Job.Run.Repo.OwnerID, err) - } - - // Repo level - repoVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{RepoID: task.Job.Run.RepoID}) - if err != nil { - log.Error("find variables of repo: %d, error: %v", task.Job.Run.RepoID, err) - } - - // Level precedence: Repo > Org / User - for _, v := range append(ownerVariables, repoVariables...) { - variables[v.Name] = v.Data - } - - return variables -} - func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { event := map[string]any{} _ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) @@ -144,6 +95,11 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { refName := git.RefName(ref) + giteaRuntimeToken, err := actions.CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) + if err != nil { + log.Error("actions.CreateAuthorizationToken failed: %v", err) + } + taskContext, err := structpb.NewStruct(map[string]any{ // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context "action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2. @@ -183,6 +139,7 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { // additional contexts "gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(), + "gitea_runtime_token": giteaRuntimeToken, }) if err != nil { log.Error("structpb.NewStruct failed: %v", err) @@ -200,7 +157,7 @@ func findTaskNeeds(ctx context.Context, task *actions_model.ActionTask) (map[str } needs := container.SetOf(task.Job.Needs...) - jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: task.Job.RunID}) + jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: task.Job.RunID}) if err != nil { return nil, fmt.Errorf("FindRunJobs: %w", err) } diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go index d499244dc3e..dae9c3dfcb2 100644 --- a/routers/api/packages/alpine/alpine.go +++ b/routers/api/packages/alpine/alpine.go @@ -14,12 +14,12 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" packages_module "code.gitea.io/gitea/modules/packages" alpine_module "code.gitea.io/gitea/modules/packages/alpine" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" alpine_service "code.gitea.io/gitea/services/packages/alpine" ) @@ -62,7 +62,7 @@ func GetRepositoryKey(ctx *context.Context) { } func GetRepositoryFile(ctx *context.Context) { - pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID) + pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -72,7 +72,7 @@ func GetRepositoryFile(ctx *context.Context) { ctx, pv, &packages_service.PackageFileInfo{ - Filename: alpine_service.IndexFilename, + Filename: alpine_service.IndexArchiveFilename, CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")), }, ) @@ -134,6 +134,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -163,7 +164,7 @@ func UploadPackageFile(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: @@ -181,19 +182,38 @@ func UploadPackageFile(ctx *context.Context) { } func DownloadPackageFile(ctx *context.Context) { - pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + branch := ctx.Params("branch") + repository := ctx.Params("repository") + architecture := ctx.Params("architecture") + + opts := &packages_model.PackageFileSearchOptions{ OwnerID: ctx.Package.Owner.ID, PackageType: packages_model.TypeAlpine, Query: ctx.Params("filename"), - CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")), - }) + CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), + } + pfs, _, err := packages_model.SearchFiles(ctx, opts) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - if len(pfs) != 1 { - apiError(ctx, http.StatusNotFound, nil) - return + if len(pfs) == 0 { + // Try again with architecture 'noarch' + if architecture == alpine_module.NoArch { + apiError(ctx, http.StatusNotFound, nil) + return + } + + opts.CompositeKey = fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch) + if pfs, _, err = packages_model.SearchFiles(ctx, opts); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pfs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } } s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) @@ -227,7 +247,7 @@ func DeletePackageFile(ctx *context.Context) { return } - if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx.Doer, pfs[0]); err != nil { + if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.Doer, pfs[0]); err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) } else { diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 2ba35e21380..5e3cbac8f9c 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -10,7 +10,6 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/perm" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" @@ -36,7 +35,7 @@ import ( "code.gitea.io/gitea/routers/api/packages/swift" "code.gitea.io/gitea/routers/api/packages/vagrant" "code.gitea.io/gitea/services/auth" - context_service "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context" ) func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { @@ -513,14 +512,75 @@ func CommonRoutes() *web.Route { r.Get("/simple/{id}", pypi.PackageMetadata) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/rpm", func() { - r.Get(".repo", rpm.GetRepositoryConfig) - r.Get("/repository.key", rpm.GetRepositoryKey) - r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile) - r.Group("/package/{name}/{version}/{architecture}", func() { - r.Get("", rpm.DownloadPackageFile) - r.Delete("", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile) + r.Group("/repository.key", func() { + r.Head("", rpm.GetRepositoryKey) + r.Get("", rpm.GetRepositoryKey) + }) + + var ( + repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`) + uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`) + filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`) + repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`) + ) + + r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) { + path := ctx.Params("*") + isHead := ctx.Req.Method == "HEAD" + isGetHead := ctx.Req.Method == "HEAD" || ctx.Req.Method == "GET" + isPut := ctx.Req.Method == "PUT" + isDelete := ctx.Req.Method == "DELETE" + + m := repoPattern.FindStringSubmatch(path) + if len(m) == 2 && isGetHead { + ctx.SetParams("group", strings.Trim(m[1], "/")) + rpm.GetRepositoryConfig(ctx) + return + } + + m = repoFilePattern.FindStringSubmatch(path) + if len(m) == 3 && isGetHead { + ctx.SetParams("group", strings.Trim(m[1], "/")) + ctx.SetParams("filename", m[2]) + if isHead { + rpm.CheckRepositoryFileExistence(ctx) + } else { + rpm.GetRepositoryFile(ctx) + } + return + } + + m = uploadPattern.FindStringSubmatch(path) + if len(m) == 2 && isPut { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + ctx.SetParams("group", strings.Trim(m[1], "/")) + rpm.UploadPackageFile(ctx) + return + } + + m = filePattern.FindStringSubmatch(path) + if len(m) == 6 && (isGetHead || isDelete) { + ctx.SetParams("group", strings.Trim(m[1], "/")) + ctx.SetParams("name", m[2]) + ctx.SetParams("version", m[3]) + ctx.SetParams("architecture", m[4]) + if isGetHead { + rpm.DownloadPackageFile(ctx) + } else { + reqPackageAccess(perm.AccessModeWrite)(ctx) + if ctx.Written() { + return + } + rpm.DeletePackageFile(ctx) + } + return + } + + ctx.Status(http.StatusNotFound) }) - r.Get("/repodata/{filename}", rpm.GetRepositoryFile) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/rubygems", func() { r.Get("/specs.4.8.gz", rubygems.EnumeratePackages) @@ -581,7 +641,7 @@ func CommonRoutes() *web.Route { }) }) }, reqPackageAccess(perm.AccessModeRead)) - }, context_service.UserAssignmentWeb(), context.PackageAssignment()) + }, context.UserAssignmentWeb(), context.PackageAssignment()) return r } @@ -600,7 +660,10 @@ func ContainerRoutes() *web.Route { }) r.Get("", container.ReqContainerAccess, container.DetermineSupport) - r.Get("/token", container.Authenticate) + r.Group("/token", func() { + r.Get("", container.Authenticate) + r.Post("", container.AuthenticateNotImplemented) + }) r.Get("/_catalog", container.ReqContainerAccess, container.GetRepositoryList) r.Group("/{username}", func() { r.Group("/{image}", func() { @@ -748,7 +811,7 @@ func ContainerRoutes() *web.Route { ctx.Status(http.StatusNotFound) }) - }, container.ReqContainerAccess, context_service.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + }, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) return r } diff --git a/routers/api/packages/cargo/cargo.go b/routers/api/packages/cargo/cargo.go index 8c370339cd5..140e532efd8 100644 --- a/routers/api/packages/cargo/cargo.go +++ b/routers/api/packages/cargo/cargo.go @@ -12,14 +12,15 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" cargo_module "code.gitea.io/gitea/modules/packages/cargo" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" packages_service "code.gitea.io/gitea/services/packages" cargo_service "code.gitea.io/gitea/services/packages/cargo" @@ -110,7 +111,7 @@ func SearchPackages(ctx *context.Context) { OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeCargo, Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: &paginator, }, ) @@ -214,6 +215,7 @@ func UploadPackage(ctx *context.Context) { } pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -249,7 +251,7 @@ func UploadPackage(ctx *context.Context) { return } - if err := cargo_service.AddOrUpdatePackageIndex(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { + if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { log.Error("Rollback creation of package version: %v", err) } @@ -300,7 +302,7 @@ func yankPackage(ctx *context.Context, yank bool) { return } - if err := cargo_service.AddOrUpdatePackageIndex(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { + if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/chef/auth.go b/routers/api/packages/chef/auth.go index 3aef8281a4e..a790e9a3631 100644 --- a/routers/api/packages/chef/auth.go +++ b/routers/api/packages/chef/auth.go @@ -8,6 +8,7 @@ import ( "crypto" "crypto/rsa" "crypto/sha1" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" @@ -26,8 +27,6 @@ import ( chef_module "code.gitea.io/gitea/modules/packages/chef" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/auth" - - "github.com/minio/sha256-simd" ) const ( diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go index 908f9fc4be1..b49f4e9d0aa 100644 --- a/routers/api/packages/chef/chef.go +++ b/routers/api/packages/chef/chef.go @@ -15,12 +15,13 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" chef_module "code.gitea.io/gitea/modules/packages/chef" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -40,7 +41,7 @@ func PackagesUniverse(ctx *context.Context) { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeChef, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -85,7 +86,7 @@ func EnumeratePackages(ctx *context.Context) { OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeChef, Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: db.NewAbsoluteListOptions( ctx.FormInt("start"), ctx.FormInt("items"), @@ -286,6 +287,7 @@ func UploadPackage(ctx *context.Context) { } _, _, err = packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -309,7 +311,7 @@ func UploadPackage(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: @@ -356,6 +358,7 @@ func DeletePackageVersion(ctx *context.Context) { packageVersion := ctx.Params("version") err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, ctx.Doer, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -390,7 +393,7 @@ func DeletePackage(ctx *context.Context) { } for _, pv := range pvs { - if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/composer/composer.go b/routers/api/packages/composer/composer.go index 75bbfdf4d3b..a045da40def 100644 --- a/routers/api/packages/composer/composer.go +++ b/routers/api/packages/composer/composer.go @@ -14,12 +14,13 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" composer_module "code.gitea.io/gitea/modules/packages/composer" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" packages_service "code.gitea.io/gitea/services/packages" @@ -66,7 +67,7 @@ func SearchPackages(ctx *context.Context) { OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeComposer, Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: &paginator, } if ctx.FormTrim("type") != "" { @@ -220,6 +221,7 @@ func UploadPackage(ctx *context.Context) { } _, _, err = packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -246,7 +248,7 @@ func UploadPackage(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go index 8edbe98b30b..c45e085a4d9 100644 --- a/routers/api/packages/conan/conan.go +++ b/routers/api/packages/conan/conan.go @@ -15,13 +15,13 @@ import ( packages_model "code.gitea.io/gitea/models/packages" conan_model "code.gitea.io/gitea/models/packages/conan" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" conan_module "code.gitea.io/gitea/modules/packages/conan" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" notify_service "code.gitea.io/gitea/services/notify" packages_service "code.gitea.io/gitea/services/packages" ) @@ -408,13 +408,14 @@ func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, pci, pfci, ) if err != nil { switch err { case packages_model.ErrDuplicatePackageFile: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/conan/search.go b/routers/api/packages/conan/search.go index 2bcf9df162b..7370c702cd6 100644 --- a/routers/api/packages/conan/search.go +++ b/routers/api/packages/conan/search.go @@ -9,9 +9,9 @@ import ( conan_model "code.gitea.io/gitea/models/packages/conan" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" conan_module "code.gitea.io/gitea/modules/packages/conan" + "code.gitea.io/gitea/services/context" ) // SearchResult contains the found recipe names diff --git a/routers/api/packages/conda/conda.go b/routers/api/packages/conda/conda.go index 0bf0fc1f625..30c80fc15e0 100644 --- a/routers/api/packages/conda/conda.go +++ b/routers/api/packages/conda/conda.go @@ -12,13 +12,13 @@ import ( packages_model "code.gitea.io/gitea/models/packages" conda_model "code.gitea.io/gitea/models/packages/conda" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" conda_module "code.gitea.io/gitea/modules/packages/conda" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" "github.com/dsnet/compress/bzip2" @@ -229,6 +229,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 7bd5cadaaf6..e5197661423 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -17,7 +17,6 @@ import ( packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" @@ -25,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" @@ -114,11 +114,15 @@ func apiErrorDefined(ctx *context.Context, err *namedError) { }) } -// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost for anonymous access) +func apiUnauthorizedError(ctx *context.Context) { + ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`) + apiErrorDefined(ctx, errUnauthorized) +} + +// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled) func ReqContainerAccess(ctx *context.Context) { - if ctx.Doer == nil { - ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`) - apiErrorDefined(ctx, errUnauthorized) + if ctx.Doer == nil || (setting.Service.RequireSignInView && ctx.Doer.IsGhost()) { + apiUnauthorizedError(ctx) } } @@ -138,10 +142,15 @@ func DetermineSupport(ctx *context.Context) { } // Authenticate creates a token for the current user -// If the current user is anonymous, the ghost user is used +// If the current user is anonymous, the ghost user is used unless RequireSignInView is enabled. func Authenticate(ctx *context.Context) { u := ctx.Doer if u == nil { + if setting.Service.RequireSignInView { + apiUnauthorizedError(ctx) + return + } + u = user_model.NewGhostUser() } @@ -156,6 +165,17 @@ func Authenticate(ctx *context.Context) { }) } +// https://distribution.github.io/distribution/spec/auth/oauth/ +func AuthenticateNotImplemented(ctx *context.Context) { + // This optional endpoint can be used to authenticate a client. + // It must implement the specification described in: + // https://datatracker.ietf.org/doc/html/rfc6749 + // https://distribution.github.io/distribution/spec/auth/oauth/ + // Purpose of this stub is to respond with 404 Not Found instead of 405 Method Not Allowed. + + ctx.Status(http.StatusNotFound) +} + // https://docs.docker.com/registry/spec/api/#listing-repositories func GetRepositoryList(ctx *context.Context) { n := ctx.FormInt("n") @@ -653,7 +673,7 @@ func DeleteManifest(ctx *context.Context) { } for _, pv := range pvs { - if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go index 0ef6eff88d6..2cec75294fe 100644 --- a/routers/api/packages/cran/cran.go +++ b/routers/api/packages/cran/cran.go @@ -13,11 +13,11 @@ import ( packages_model "code.gitea.io/gitea/models/packages" cran_model "code.gitea.io/gitea/models/packages/cran" - "code.gitea.io/gitea/modules/context" packages_module "code.gitea.io/gitea/modules/packages" cran_module "code.gitea.io/gitea/modules/packages/cran" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -183,6 +183,7 @@ func uploadPackageFile(ctx *context.Context, compositeKey string, properties map } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/debian/debian.go b/routers/api/packages/debian/debian.go index 869bc1e9015..241de3ac5d9 100644 --- a/routers/api/packages/debian/debian.go +++ b/routers/api/packages/debian/debian.go @@ -13,11 +13,11 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" packages_module "code.gitea.io/gitea/modules/packages" debian_module "code.gitea.io/gitea/modules/packages/debian" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" notify_service "code.gitea.io/gitea/services/notify" packages_service "code.gitea.io/gitea/services/packages" debian_service "code.gitea.io/gitea/services/packages/debian" @@ -45,7 +45,7 @@ func GetRepositoryKey(ctx *context.Context) { // https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files // https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices func GetRepositoryFile(ctx *context.Context) { - pv, err := debian_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID) + pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -81,7 +81,7 @@ func GetRepositoryFile(ctx *context.Context) { // https://wiki.debian.org/DebianRepository/Format#indices_acquisition_via_hashsums_.28by-hash.29 func GetRepositoryFileByHash(ctx *context.Context) { - pv, err := debian_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID) + pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -159,6 +159,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -188,7 +189,7 @@ func UploadPackageFile(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/generic/generic.go b/routers/api/packages/generic/generic.go index c5866ef9c35..82329311340 100644 --- a/routers/api/packages/generic/generic.go +++ b/routers/api/packages/generic/generic.go @@ -8,18 +8,19 @@ import ( "net/http" "regexp" "strings" + "unicode" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) var ( - packageNameRegex = regexp.MustCompile(`\A[A-Za-z0-9\.\_\-\+]+\z`) - filenameRegex = packageNameRegex + packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`) + filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`) ) func apiError(ctx *context.Context, status int, obj any) { @@ -54,20 +55,38 @@ func DownloadPackageFile(ctx *context.Context) { helper.ServePackageFile(ctx, s, u, pf) } +func isValidPackageName(packageName string) bool { + if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) { + return false + } + return packageNameRegex.MatchString(packageName) && packageName != ".." +} + +func isValidFileName(filename string) bool { + return filenameRegex.MatchString(filename) && + strings.TrimSpace(filename) == filename && + filename != "." && filename != ".." +} + // UploadPackage uploads the specific generic package. // Duplicated packages get rejected. func UploadPackage(ctx *context.Context) { packageName := ctx.Params("packagename") filename := ctx.Params("filename") - if !packageNameRegex.MatchString(packageName) || !filenameRegex.MatchString(filename) { - apiError(ctx, http.StatusBadRequest, errors.New("Invalid package name or filename")) + if !isValidPackageName(packageName) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid package name")) + return + } + + if !isValidFileName(filename) { + apiError(ctx, http.StatusBadRequest, errors.New("invalid filename")) return } packageVersion := ctx.Params("packageversion") if packageVersion != strings.TrimSpace(packageVersion) { - apiError(ctx, http.StatusBadRequest, errors.New("Invalid package version")) + apiError(ctx, http.StatusBadRequest, errors.New("invalid package version")) return } @@ -89,6 +108,7 @@ func UploadPackage(ctx *context.Context) { defer buf.Close() _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -125,6 +145,7 @@ func UploadPackage(ctx *context.Context) { // DeletePackage deletes the specific generic package. func DeletePackage(ctx *context.Context) { err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, ctx.Doer, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -176,7 +197,7 @@ func DeletePackageFile(ctx *context.Context) { } if len(pfs) == 1 { - if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/generic/generic_test.go b/routers/api/packages/generic/generic_test.go new file mode 100644 index 00000000000..1acaafe576a --- /dev/null +++ b/routers/api/packages/generic/generic_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package generic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatePackageName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "-", + "a?b", + "a b", + "a/b", + } + for _, name := range bad { + assert.False(t, isValidPackageName(name), "bad=%q", name) + } + + good := []string{ + "a", + "1", + "a-", + "a_b", + "c.d+", + } + for _, name := range good { + assert.True(t, isValidPackageName(name), "good=%q", name) + } +} + +func TestValidateFileName(t *testing.T) { + bad := []string{ + "", + ".", + "..", + "a?b", + "a/b", + " a", + "a ", + } + for _, name := range bad { + assert.False(t, isValidFileName(name), "bad=%q", name) + } + + good := []string{ + "-", + "a", + "1", + "a-", + "a_b", + "a b", + "c.d+", + `-_+=:;.()[]{}~!@#$%^& aA1`, + } + for _, name := range good { + assert.True(t, isValidFileName(name), "good=%q", name) + } +} diff --git a/routers/api/packages/goproxy/goproxy.go b/routers/api/packages/goproxy/goproxy.go index bacdc4ec62f..d658066bb43 100644 --- a/routers/api/packages/goproxy/goproxy.go +++ b/routers/api/packages/goproxy/goproxy.go @@ -12,11 +12,12 @@ import ( "time" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" goproxy_module "code.gitea.io/gitea/modules/packages/goproxy" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -129,7 +130,7 @@ func resolvePackage(ctx *context.Context, ownerID int64, name, version string) ( Value: name, ExactMatch: true, }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Sort: packages_model.SortCreatedDesc, }) if err != nil { @@ -185,6 +186,7 @@ func UploadPackage(ctx *context.Context) { } _, _, err = packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go index 9097adf29e7..efdb83ec0e7 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -13,14 +13,15 @@ import ( "time" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" helm_module "code.gitea.io/gitea/modules/packages/helm" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" "gopkg.in/yaml.v3" @@ -42,7 +43,7 @@ func Index(ctx *context.Context) { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeHelm, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -110,7 +111,7 @@ func DownloadPackageFile(ctx *context.Context) { Value: ctx.Params("package"), }, HasFileWithName: filename, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -174,6 +175,7 @@ func UploadPackage(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go index aadb10376c8..cdb64109ade 100644 --- a/routers/api/packages/helper/helper.go +++ b/routers/api/packages/helper/helper.go @@ -10,9 +10,9 @@ import ( "net/url" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) // LogAndProcessError logs an error and calls a custom callback with the processed error message. diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go index 6328e226ab6..27f0578db70 100644 --- a/routers/api/packages/maven/maven.go +++ b/routers/api/packages/maven/maven.go @@ -6,6 +6,7 @@ package maven import ( "crypto/md5" "crypto/sha1" + "crypto/sha256" "crypto/sha512" "encoding/hex" "encoding/xml" @@ -19,15 +20,13 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" maven_module "code.gitea.io/gitea/modules/packages/maven" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" - - "github.com/minio/sha256-simd" ) const ( @@ -356,13 +355,14 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, pvci, pfci, ) if err != nil { switch err { case packages_model.ErrDuplicatePackageFile: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/npm/api.go b/routers/api/packages/npm/api.go index 84708748842..f8e839c4241 100644 --- a/routers/api/packages/npm/api.go +++ b/routers/api/packages/npm/api.go @@ -12,6 +12,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" npm_module "code.gitea.io/gitea/modules/packages/npm" + "code.gitea.io/gitea/modules/setting" ) func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *npm_module.PackageMetadata { @@ -98,7 +99,7 @@ func createPackageSearchResponse(pds []*packages_model.PackageDescriptor, total Maintainers: []npm_module.User{}, // npm cli needs this field Keywords: metadata.Keywords, Links: &npm_module.PackageSearchPackageLinks{ - Registry: pd.FullWebLink(), + Registry: setting.AppURL + "api/packages/" + pd.Owner.Name + "/npm", Homepage: metadata.ProjectURL, }, }, diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go index d1e271f23ff..84acfffae25 100644 --- a/routers/api/packages/npm/npm.go +++ b/routers/api/packages/npm/npm.go @@ -17,12 +17,13 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" npm_module "code.gitea.io/gitea/modules/packages/npm" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" "github.com/hashicorp/go-version" @@ -120,7 +121,7 @@ func DownloadPackageFileByName(ctx *context.Context) { Value: packageNameFromParams(ctx), }, HasFileWithName: filename, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -190,6 +191,7 @@ func UploadPackage(ctx *context.Context) { defer buf.Close() pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -213,7 +215,7 @@ func UploadPackage(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: @@ -255,6 +257,7 @@ func DeletePackageVersion(ctx *context.Context) { packageVersion := ctx.Params("version") err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, ctx.Doer, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -291,7 +294,7 @@ func DeletePackage(ctx *context.Context) { } for _, pv := range pvs { - if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -393,7 +396,7 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe Properties: map[string]string{ npm_module.TagProperty: tag, }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { return err @@ -429,7 +432,7 @@ func PackageSearch(ctx *context.Context) { pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeNpm, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Name: packages_model.SearchValue{ ExactMatch: false, Value: ctx.FormTrim("text"), diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go index 6f63c1d4c21..c28bc6c9d92 100644 --- a/routers/api/packages/nuget/nuget.go +++ b/routers/api/packages/nuget/nuget.go @@ -17,13 +17,14 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" nuget_model "code.gitea.io/gitea/models/packages/nuget" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" nuget_module "code.gitea.io/gitea/modules/packages/nuget" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -122,7 +123,7 @@ func SearchServiceV2(ctx *context.Context) { Name: packages_model.SearchValue{ Value: getSearchTerm(ctx), }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: paginator, }) if err != nil { @@ -172,7 +173,7 @@ func SearchServiceV2Count(ctx *context.Context) { Name: packages_model.SearchValue{ Value: getSearchTerm(ctx), }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -187,7 +188,7 @@ func SearchServiceV3(ctx *context.Context) { pvs, count, err := nuget_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: db.NewAbsoluteListOptions( ctx.FormInt("skip"), ctx.FormInt("take"), @@ -313,7 +314,7 @@ func EnumeratePackageVersionsV2(ctx *context.Context) { ExactMatch: true, Value: packageName, }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: paginator, }) if err != nil { @@ -358,7 +359,7 @@ func EnumeratePackageVersionsV2Count(ctx *context.Context) { ExactMatch: true, Value: strings.Trim(ctx.FormTrim("id"), "'"), }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -431,6 +432,7 @@ func UploadPackage(ctx *context.Context) { } _, _, err := packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -503,6 +505,7 @@ func UploadSymbolPackage(ctx *context.Context) { } _, err = packages_service.AddFileToExistingPackage( + ctx, pi, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ @@ -529,6 +532,7 @@ func UploadSymbolPackage(ctx *context.Context) { for _, pdb := range pdbs { _, err := packages_service.AddFileToExistingPackage( + ctx, pi, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ @@ -647,6 +651,7 @@ func DeletePackage(ctx *context.Context) { packageVersion := ctx.Params("version") err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, ctx.Doer, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/pub/pub.go b/routers/api/packages/pub/pub.go index ef07836b886..f87df52a29a 100644 --- a/routers/api/packages/pub/pub.go +++ b/routers/api/packages/pub/pub.go @@ -14,7 +14,6 @@ import ( "time" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" @@ -22,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -189,6 +189,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -212,7 +213,7 @@ func UploadPackageFile(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go index d97b894bbed..7824db18235 100644 --- a/routers/api/packages/pypi/pypi.go +++ b/routers/api/packages/pypi/pypi.go @@ -12,12 +12,12 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" packages_module "code.gitea.io/gitea/modules/packages" pypi_module "code.gitea.io/gitea/modules/packages/pypi" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -145,6 +145,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -176,7 +177,7 @@ func UploadPackageFile(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageFile: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: diff --git a/routers/api/packages/rpm/rpm.go b/routers/api/packages/rpm/rpm.go index 65b7c74bdd3..4de361c2142 100644 --- a/routers/api/packages/rpm/rpm.go +++ b/routers/api/packages/rpm/rpm.go @@ -13,13 +13,13 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" packages_module "code.gitea.io/gitea/modules/packages" rpm_module "code.gitea.io/gitea/modules/packages/rpm" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" notify_service "code.gitea.io/gitea/services/notify" packages_service "code.gitea.io/gitea/services/packages" rpm_service "code.gitea.io/gitea/services/packages/rpm" @@ -33,11 +33,18 @@ func apiError(ctx *context.Context, status int, obj any) { // https://dnf.readthedocs.io/en/latest/conf_ref.html func GetRepositoryConfig(ctx *context.Context) { + group := ctx.Params("group") + + var groupParts []string + if group != "" { + groupParts = strings.Split(group, "/") + } + url := fmt.Sprintf("%sapi/packages/%s/rpm", setting.AppURL, ctx.Package.Owner.Name) - ctx.PlainText(http.StatusOK, `[gitea-`+ctx.Package.Owner.LowerName+`] -name=`+ctx.Package.Owner.Name+` - `+setting.AppName+` -baseurl=`+url+` + ctx.PlainText(http.StatusOK, `[gitea-`+strings.Join(append([]string{ctx.Package.Owner.LowerName}, groupParts...), "-")+`] +name=`+strings.Join(append([]string{ctx.Package.Owner.Name, setting.AppName}, groupParts...), " - ")+` +baseurl=`+strings.Join(append([]string{url}, groupParts...), "/")+` enabled=1 gpgcheck=1 gpgkey=`+url+`/repository.key`) @@ -57,9 +64,33 @@ func GetRepositoryKey(ctx *context.Context) { }) } +func CheckRepositoryFileExistence(ctx *context.Context) { + pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.Params("filename"), ctx.Params("group")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Status(http.StatusNotFound) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.SetServeHeaders(&context.ServeHeaderOptions{ + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + }) + ctx.Status(http.StatusOK) +} + // Gets a pre-generated repository metadata file func GetRepositoryFile(ctx *context.Context) { - pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID) + pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -69,7 +100,8 @@ func GetRepositoryFile(ctx *context.Context) { ctx, pv, &packages_service.PackageFileInfo{ - Filename: ctx.Params("filename"), + Filename: ctx.Params("filename"), + CompositeKey: ctx.Params("group"), }, ) if err != nil { @@ -121,8 +153,9 @@ func UploadPackageFile(ctx *context.Context) { apiError(ctx, http.StatusInternalServerError, err) return } - + group := ctx.Params("group") _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -135,13 +168,16 @@ func UploadPackageFile(ctx *context.Context) { }, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ - Filename: fmt.Sprintf("%s-%s.%s.rpm", pck.Name, pck.Version, pck.FileMetadata.Architecture), + Filename: fmt.Sprintf("%s-%s.%s.rpm", pck.Name, pck.Version, pck.FileMetadata.Architecture), + CompositeKey: group, }, Creator: ctx.Doer, Data: buf, IsLead: true, Properties: map[string]string{ - rpm_module.PropertyMetadata: string(fileMetadataRaw), + rpm_module.PropertyGroup: group, + rpm_module.PropertyArchitecture: pck.FileMetadata.Architecture, + rpm_module.PropertyMetadata: string(fileMetadataRaw), }, }, ) @@ -157,7 +193,7 @@ func UploadPackageFile(ctx *context.Context) { return } - if err := rpm_service.BuildRepositoryFiles(ctx, ctx.Package.Owner.ID); err != nil { + if err := rpm_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, group); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -178,7 +214,8 @@ func DownloadPackageFile(ctx *context.Context) { Version: version, }, &packages_service.PackageFileInfo{ - Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.Params("architecture")), + Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.Params("architecture")), + CompositeKey: ctx.Params("group"), }, ) if err != nil { @@ -194,6 +231,7 @@ func DownloadPackageFile(ctx *context.Context) { } func DeletePackageFile(webctx *context.Context) { + group := webctx.Params("group") name := webctx.Params("name") version := webctx.Params("version") architecture := webctx.Params("architecture") @@ -201,7 +239,12 @@ func DeletePackageFile(webctx *context.Context) { var pd *packages_model.PackageDescriptor err := db.WithTx(webctx, func(ctx stdctx.Context) error { - pv, err := packages_model.GetVersionByNameAndVersion(ctx, webctx.Package.Owner.ID, packages_model.TypeRpm, name, version) + pv, err := packages_model.GetVersionByNameAndVersion(ctx, + webctx.Package.Owner.ID, + packages_model.TypeRpm, + name, + version, + ) if err != nil { return err } @@ -210,7 +253,7 @@ func DeletePackageFile(webctx *context.Context) { ctx, pv.ID, fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture), - packages_model.EmptyFileKey, + group, ) if err != nil { return err @@ -250,7 +293,7 @@ func DeletePackageFile(webctx *context.Context) { notify_service.PackageDelete(webctx, webctx.Doer, pd) } - if err := rpm_service.BuildRepositoryFiles(webctx, webctx.Package.Owner.ID); err != nil { + if err := rpm_service.BuildSpecificRepositoryFiles(webctx, webctx.Package.Owner.ID, group); err != nil { apiError(webctx, http.StatusInternalServerError, err) return } diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go index 88d70f10bdc..d2fbcd01f06 100644 --- a/routers/api/packages/rubygems/rubygems.go +++ b/routers/api/packages/rubygems/rubygems.go @@ -13,11 +13,12 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" rubygems_module "code.gitea.io/gitea/modules/packages/rubygems" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" ) @@ -43,7 +44,7 @@ func EnumeratePackagesLatest(ctx *context.Context) { pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeRubyGems, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -234,6 +235,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -257,7 +259,7 @@ func UploadPackageFile(ctx *context.Context) { if err != nil { switch err { case packages_model.ErrDuplicatePackageVersion: - apiError(ctx, http.StatusBadRequest, err) + apiError(ctx, http.StatusConflict, err) case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: apiError(ctx, http.StatusForbidden, err) default: @@ -280,6 +282,7 @@ func DeletePackage(ctx *context.Context) { packageVersion := ctx.FormString("version") err := packages_service.RemovePackageVersionByNameAndVersion( + ctx, ctx.Doer, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -302,7 +305,7 @@ func getVersionsByFilename(ctx *context.Context, filename string) ([]*packages_m OwnerID: ctx.Package.Owner.ID, Type: packages_model.TypeRubyGems, HasFileWithName: filename, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) return pvs, err } diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go index bd4b8095c2c..a9da3ea9c2d 100644 --- a/routers/api/packages/swift/swift.go +++ b/routers/api/packages/swift/swift.go @@ -13,14 +13,15 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" swift_module "code.gitea.io/gitea/modules/packages/swift" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" "github.com/hashicorp/go-version" @@ -157,7 +158,7 @@ func EnumeratePackageVersions(ctx *context.Context) { } type Resource struct { - Name string `json:"id"` + Name string `json:"name"` Type string `json:"type"` Checksum string `json:"checksum"` } @@ -329,6 +330,7 @@ func UploadPackageFile(ctx *context.Context) { } pv, _, err := packages_service.CreatePackageAndAddFile( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -432,7 +434,7 @@ func LookupPackageIdentifiers(ctx *context.Context) { Properties: map[string]string{ swift_module.PropertyRepositoryURL: url, }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) diff --git a/routers/api/packages/vagrant/vagrant.go b/routers/api/packages/vagrant/vagrant.go index 9fe7ab56f66..98a81da368e 100644 --- a/routers/api/packages/vagrant/vagrant.go +++ b/routers/api/packages/vagrant/vagrant.go @@ -12,11 +12,11 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" packages_module "code.gitea.io/gitea/modules/packages" vagrant_module "code.gitea.io/gitea/modules/packages/vagrant" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers/api/packages/helper" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" "github.com/hashicorp/go-version" @@ -177,6 +177,7 @@ func UploadPackageFile(ctx *context.Context) { } _, _, err = packages_service.CreatePackageOrAddFileToExisting( + ctx, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index cad5032d103..995a148f0bd 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -9,9 +9,9 @@ import ( "strings" "code.gitea.io/gitea/modules/activitypub" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ap "github.com/go-ap/activitypub" "github.com/go-ap/jsonld" diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index 3f60ed7776a..59ebc74b89e 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -13,9 +13,9 @@ import ( "net/url" "code.gitea.io/gitea/modules/activitypub" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" + gitea_context "code.gitea.io/gitea/services/context" ap "github.com/go-ap/activitypub" "github.com/go-fed/httpsig" diff --git a/routers/api/v1/admin/adopt.go b/routers/api/v1/admin/adopt.go index bf030eb222b..a4708fe0326 100644 --- a/routers/api/v1/admin/adopt.go +++ b/routers/api/v1/admin/adopt.go @@ -8,9 +8,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) diff --git a/routers/api/v1/admin/cron.go b/routers/api/v1/admin/cron.go index cc8c6c9e239..e1ca6048c93 100644 --- a/routers/api/v1/admin/cron.go +++ b/routers/api/v1/admin/cron.go @@ -6,11 +6,11 @@ package admin import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/cron" ) diff --git a/routers/api/v1/admin/email.go b/routers/api/v1/admin/email.go index 5914215bc2a..ba963e9f69d 100644 --- a/routers/api/v1/admin/email.go +++ b/routers/api/v1/admin/email.go @@ -7,9 +7,9 @@ import ( "net/http" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go index 8a095a7defa..4c168b55bf1 100644 --- a/routers/api/v1/admin/hooks.go +++ b/routers/api/v1/admin/hooks.go @@ -8,12 +8,13 @@ import ( "net/http" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" ) @@ -37,7 +38,7 @@ func ListHooks(ctx *context.APIContext) { // "200": // "$ref": "#/responses/HookList" - sysHooks, err := webhook.GetSystemWebhooks(ctx, util.OptionalBoolNone) + sysHooks, err := webhook.GetSystemWebhooks(ctx, optional.None[bool]()) if err != nil { ctx.Error(http.StatusInternalServerError, "GetSystemWebhooks", err) return diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index 65620459b7a..a5c299bbf01 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -10,10 +10,10 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -62,7 +62,7 @@ func CreateOrg(ctx *context.APIContext) { Visibility: visibility, } - if err := organization.CreateOrganization(org, ctx.ContextUser); err != nil { + if err := organization.CreateOrganization(ctx, org, ctx.ContextUser); err != nil { if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNameCharsNotAllowed(err) || diff --git a/routers/api/v1/admin/repo.go b/routers/api/v1/admin/repo.go index a4895f260be..c119d5390a2 100644 --- a/routers/api/v1/admin/repo.go +++ b/routers/api/v1/admin/repo.go @@ -4,10 +4,10 @@ package admin import ( - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/repo" + "code.gitea.io/gitea/services/context" ) // CreateRepo api for creating a repository diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go new file mode 100644 index 00000000000..329242d9f6e --- /dev/null +++ b/routers/api/v1/admin/runners.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization + +// GetRegistrationToken returns the token to register global runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /admin/runners/registration-token admin adminGetRunnerRegistrationToken + // --- + // summary: Get an global actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, 0, 0) +} diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 66946e5797a..87a5b28fad0 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "net/http" - "strings" "code.gitea.io/gitea/models" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -16,16 +15,16 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/mailer" user_service "code.gitea.io/gitea/services/user" @@ -36,7 +35,7 @@ func parseAuthSource(ctx *context.APIContext, u *user_model.User, sourceID int64 return } - source, err := auth.GetSourceByID(sourceID) + source, err := auth.GetSourceByID(ctx, sourceID) if err != nil { if auth.IsErrSourceNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) @@ -93,26 +92,32 @@ func CreateUser(ctx *context.APIContext) { if ctx.Written() { return } - if !password.IsComplexEnough(form.Password) { - err := errors.New("PasswordComplexity") - ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) - return - } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { - if err != nil { - log.Error(err.Error()) + + if u.LoginType == auth.Plain { + if len(form.Password) < setting.MinPasswordLength { + err := errors.New("PasswordIsRequired") + ctx.Error(http.StatusBadRequest, "PasswordIsRequired", err) + return } - ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) - return - } - overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + if !password.IsComplexEnough(form.Password) { + err := errors.New("PasswordComplexity") + ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) + return + } + + if err := password.IsPwned(ctx, form.Password); err != nil { + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + } + ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) + return + } } - if form.Restricted != nil { - overwriteDefault.IsRestricted = util.OptionalBoolOf(*form.Restricted) + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(true), + IsRestricted: optional.FromPtr(form.Restricted), } if form.Visibility != "" { @@ -128,7 +133,7 @@ func CreateUser(ctx *context.APIContext) { u.UpdatedUnix = u.CreatedUnix } - if err := user_model.CreateUser(ctx, u, overwriteDefault); err != nil { + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { if user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err) || db.IsErrNameReserved(err) || @@ -142,6 +147,11 @@ func CreateUser(ctx *context.APIContext) { } return } + + if !user_model.IsEmailDomainAllowed(u.Email) { + ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email)) + } + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. @@ -173,6 +183,8 @@ func EditUser(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/User" + // "400": + // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" // "422": @@ -180,111 +192,69 @@ func EditUser(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.EditUserOption) - parseAuthSource(ctx, ctx.ContextUser, form.SourceID, form.LoginName) - if ctx.Written() { - return + authOpts := &user_service.UpdateAuthOptions{ + LoginSource: optional.FromNonDefault(form.SourceID), + LoginName: optional.Some(form.LoginName), + Password: optional.FromNonDefault(form.Password), + MustChangePassword: optional.FromPtr(form.MustChangePassword), + ProhibitLogin: optional.FromPtr(form.ProhibitLogin), } - - if len(form.Password) != 0 { - if len(form.Password) < setting.MinPasswordLength { + if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): ctx.Error(http.StatusBadRequest, "PasswordTooShort", fmt.Errorf("password must be at least %d characters", setting.MinPasswordLength)) - return - } - if !password.IsComplexEnough(form.Password) { - err := errors.New("PasswordComplexity") + case errors.Is(err, password.ErrComplexity): ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) - return - } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { - if err != nil { - log.Error(err.Error()) - } - ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) - return - } - if ctx.ContextUser.Salt, err = user_model.GetUserSalt(); err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateUser", err) - return - } - if err = ctx.ContextUser.SetPassword(form.Password); err != nil { - ctx.InternalServerError(err) - return + case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err): + ctx.Error(http.StatusBadRequest, "PasswordIsPwned", err) + default: + ctx.Error(http.StatusInternalServerError, "UpdateAuth", err) } + return } - if form.MustChangePassword != nil { - ctx.ContextUser.MustChangePassword = *form.MustChangePassword - } - - ctx.ContextUser.LoginName = form.LoginName - - if form.FullName != nil { - ctx.ContextUser.FullName = *form.FullName - } - var emailChanged bool if form.Email != nil { - email := strings.TrimSpace(*form.Email) - if len(email) == 0 { - ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("email is not allowed to be empty string")) + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Error(http.StatusBadRequest, "EmailInvalid", err) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Error(http.StatusBadRequest, "EmailUsed", err) + default: + ctx.Error(http.StatusInternalServerError, "AddOrSetPrimaryEmailAddress", err) + } return } - if err := user_model.ValidateEmail(email); err != nil { - ctx.InternalServerError(err) - return + if !user_model.IsEmailDomainAllowed(*form.Email) { + ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email)) } - - emailChanged = !strings.EqualFold(ctx.ContextUser.Email, email) - ctx.ContextUser.Email = email - } - if form.Website != nil { - ctx.ContextUser.Website = *form.Website - } - if form.Location != nil { - ctx.ContextUser.Location = *form.Location - } - if form.Description != nil { - ctx.ContextUser.Description = *form.Description - } - if form.Active != nil { - ctx.ContextUser.IsActive = *form.Active - } - if len(form.Visibility) != 0 { - ctx.ContextUser.Visibility = api.VisibilityModes[form.Visibility] - } - if form.Admin != nil { - ctx.ContextUser.IsAdmin = *form.Admin - } - if form.AllowGitHook != nil { - ctx.ContextUser.AllowGitHook = *form.AllowGitHook - } - if form.AllowImportLocal != nil { - ctx.ContextUser.AllowImportLocal = *form.AllowImportLocal - } - if form.MaxRepoCreation != nil { - ctx.ContextUser.MaxRepoCreation = *form.MaxRepoCreation - } - if form.AllowCreateOrganization != nil { - ctx.ContextUser.AllowCreateOrganization = *form.AllowCreateOrganization - } - if form.ProhibitLogin != nil { - ctx.ContextUser.ProhibitLogin = *form.ProhibitLogin - } - if form.Restricted != nil { - ctx.ContextUser.IsRestricted = *form.Restricted } - if err := user_model.UpdateUser(ctx, ctx.ContextUser, emailChanged); err != nil { - if user_model.IsErrEmailAlreadyUsed(err) || - user_model.IsErrEmailCharIsNotSupported(err) || - user_model.IsErrEmailInvalid(err) { - ctx.Error(http.StatusUnprocessableEntity, "", err) + opts := &user_service.UpdateOptions{ + FullName: optional.FromPtr(form.FullName), + Website: optional.FromPtr(form.Website), + Location: optional.FromPtr(form.Location), + Description: optional.FromPtr(form.Description), + IsActive: optional.FromPtr(form.Active), + IsAdmin: optional.FromPtr(form.Admin), + Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), + AllowGitHook: optional.FromPtr(form.AllowGitHook), + AllowImportLocal: optional.FromPtr(form.AllowImportLocal), + MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation), + AllowCreateOrganization: optional.FromPtr(form.AllowCreateOrganization), + IsRestricted: optional.FromPtr(form.Restricted), + } + + if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil { + if models.IsErrDeleteLastAdminUser(err) { + ctx.Error(http.StatusBadRequest, "LastAdmin", err) } else { ctx.Error(http.StatusInternalServerError, "UpdateUser", err) } return } + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, ctx.ContextUser.Name) ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer)) @@ -331,7 +301,8 @@ func DeleteUser(ctx *context.APIContext) { if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil { if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || - models.IsErrUserOwnPackages(err) { + models.IsErrUserOwnPackages(err) || + models.IsErrDeleteLastAdminUser(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) } else { ctx.Error(http.StatusInternalServerError, "DeleteUser", err) @@ -402,7 +373,7 @@ func DeleteUserPublicKey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := asymkey_service.DeletePublicKey(ctx.ContextUser, ctx.ParamsInt64(":id")); err != nil { + if err := asymkey_service.DeletePublicKey(ctx, ctx.ContextUser, ctx.ParamsInt64(":id")); err != nil { if asymkey_model.IsErrKeyNotExist(err) { ctx.NotFound() } else if asymkey_model.IsErrKeyAccessDenied(err) { @@ -510,9 +481,6 @@ func RenameUser(ctx *context.APIContext) { // Check if user name has been changed if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil { switch { - case user_model.IsErrUsernameNotChanged(err): - // Noop as username is not changed - ctx.Status(http.StatusNoContent) case user_model.IsErrUserAlreadyExist(err): ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken")) case db.IsErrNameReserved(err): @@ -528,5 +496,5 @@ func RenameUser(ctx *context.APIContext) { } log.Trace("User name changed: %s -> %s", oldName, newName) - ctx.Status(http.StatusOK) + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/admin/user_badge.go b/routers/api/v1/admin/user_badge.go new file mode 100644 index 00000000000..bacd1f809bf --- /dev/null +++ b/routers/api/v1/admin/user_badge.go @@ -0,0 +1,124 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// ListUserBadges lists all badges belonging to a user +func ListUserBadges(ctx *context.APIContext) { + // swagger:operation GET /admin/users/{username}/badges admin adminListUserBadges + // --- + // summary: List a user's badges + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/BadgeList" + // "404": + // "$ref": "#/responses/notFound" + + badges, maxResults, err := user_model.GetUserBadges(ctx, ctx.ContextUser) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserBadges", err) + return + } + + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &badges) +} + +// AddUserBadges add badges to a user +func AddUserBadges(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/badges admin adminAddUserBadges + // --- + // summary: Add a badge to a user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserBadgeOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.UserBadgeOption) + badges := prepareBadgesForReplaceOrAdd(ctx, *form) + + if err := user_model.AddUserBadges(ctx, ctx.ContextUser, badges); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceUserBadges", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteUserBadges delete a badge from a user +func DeleteUserBadges(ctx *context.APIContext) { + // swagger:operation DELETE /admin/users/{username}/badges admin adminDeleteUserBadges + // --- + // summary: Remove a badge from a user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserBadgeOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.UserBadgeOption) + badges := prepareBadgesForReplaceOrAdd(ctx, *form) + + if err := user_model.RemoveUserBadges(ctx, ctx.ContextUser, badges); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceUserBadges", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func prepareBadgesForReplaceOrAdd(ctx *context.APIContext, form api.UserBadgeOption) []*user_model.Badge { + badges := make([]*user_model.Badge, len(form.BadgeSlugs)) + for i, badge := range form.BadgeSlugs { + badges[i] = &user_model.Badge{ + Slug: badge, + } + } + return badges +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ca74a23a4b8..e870378c4b2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -2,13 +2,13 @@ // Copyright 2016 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -// Package v1 Gitea API. +// Package v1 Gitea API // // This documentation describes the Gitea API. // -// Schemes: http, https +// Schemes: https, http // BasePath: /api/v1 -// Version: {{AppVer | JSEscape | Safe}} +// Version: {{AppVer | JSEscape}} // License: MIT http://opensource.org/licenses/MIT // // Consumes: @@ -35,10 +35,12 @@ // type: apiKey // name: token // in: query +// description: This authentication option is deprecated for removal in Gitea 1.23. Please use AuthorizationHeaderToken instead. // AccessToken: // type: apiKey // name: access_token // in: query +// description: This authentication option is deprecated for removal in Gitea 1.23. Please use AuthorizationHeaderToken instead. // AuthorizationHeaderToken: // type: apiKey // name: Authorization @@ -70,13 +72,13 @@ import ( actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -92,7 +94,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/auth" - context_service "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" _ "code.gitea.io/gitea/routers/api/v1/swagger" // for swagger generation @@ -148,7 +150,7 @@ func repoAssignment() func(ctx *context.APIContext) { owner, err = user_model.GetUserByName(ctx, userName) if err != nil { if user_model.IsErrUserNotExist(err) { - if redirectUserID, err := user_model.LookupUserRedirect(userName); err == nil { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { context.RedirectToUser(ctx.Base, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetUserByName", err) @@ -165,10 +167,10 @@ func repoAssignment() func(ctx *context.APIContext) { ctx.ContextUser = owner // Get repository. - repo, err := repo_model.GetRepositoryByName(owner.ID, repoName) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) if err != nil { if repo_model.IsErrRepoNotExist(err) { - redirectRepoID, err := repo_model.LookupRedirect(owner.ID, repoName) + redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName) if err == nil { context.RedirectToRepo(ctx.Base, redirectRepoID) } else if repo_model.IsErrRedirectNotExist(err) { @@ -315,10 +317,6 @@ func reqToken() func(ctx *context.APIContext) { return } - if ctx.IsBasicAuth { - ctx.CheckForOTP() - return - } if ctx.IsSigned { return } @@ -343,7 +341,6 @@ func reqBasicOrRevProxyAuth() func(ctx *context.APIContext) { ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth required") return } - ctx.CheckForOTP() } } @@ -367,6 +364,16 @@ func reqOwner() func(ctx *context.APIContext) { } } +// reqSelfOrAdmin doer should be the same as the contextUser or site admin +func reqSelfOrAdmin() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { + ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") + return + } + } +} + // reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin func reqAdmin() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { @@ -554,7 +561,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { ctx.Org.Organization, err = organization.GetOrgByName(ctx, ctx.Params(":org")) if err != nil { if organization.IsErrOrgNotExist(err) { - redirectUserID, err := user_model.LookupUserRedirect(ctx.Params(":org")) + redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.Params(":org")) if err == nil { context.RedirectToUser(ctx.Base, ctx.Params(":org"), redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { @@ -665,7 +672,7 @@ func mustEnableWiki(ctx *context.APIContext) { func mustNotBeArchived(ctx *context.APIContext) { if ctx.Repo.Repository.IsArchived { - ctx.NotFound() + ctx.Error(http.StatusLocked, "RepoArchived", fmt.Errorf("%s is archived", ctx.Repo.Repository.LogString())) return } } @@ -690,12 +697,6 @@ func bind[T any](_ T) any { } } -// The OAuth2 plugin is expected to be executed first, as it must ignore the user id stored -// in the session (if there is a user id stored in session other plugins might return the user -// object for that id). -// -// The Session plugin is expected to be executed second, in order to skip authentication -// for users that have already signed in. func buildAuthGroup() *auth.Group { group := auth.NewGroup( &auth.OAuth2{}, @@ -705,7 +706,10 @@ func buildAuthGroup() *auth.Group { if setting.Service.EnableReverseProxyAuthAPI { group.Add(&auth.ReverseProxy{}) } - specialAdd(group) + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth.SSPI{}) // it MUST be the last, see the comment of SSPI + } return group } @@ -772,31 +776,6 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC }) return } - if ctx.IsSigned && ctx.IsBasicAuth { - if skip, ok := ctx.Data["SkipLocalTwoFA"]; ok && skip.(bool) { - return // Skip 2FA - } - twofa, err := auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID) - if err != nil { - if auth_model.IsErrTwoFactorNotEnrolled(err) { - return // No 2FA enrollment for this user - } - ctx.InternalServerError(err) - return - } - otpHeader := ctx.Req.Header.Get("X-Gitea-OTP") - ok, err := twofa.ValidateTOTP(otpHeader) - if err != nil { - ctx.InternalServerError(err) - return - } - if !ok { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in user is allowed to call APIs.", - }) - return - } - } } if options.AdminRequired { @@ -810,6 +789,31 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC } } +func individualPermsChecker(ctx *context.APIContext) { + // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. + if ctx.ContextUser.IsIndividual() { + switch { + case ctx.ContextUser.Visibility == api.VisibleTypePrivate: + if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { + ctx.NotFound("Visit Project", nil) + return + } + case ctx.ContextUser.Visibility == api.VisibleTypeLimited: + if ctx.Doer == nil { + ctx.NotFound("Visit Project", nil) + return + } + } + } +} + +// check for and warn against deprecated authentication options +func checkDeprecatedAuthMethods(ctx *context.APIContext) { + if ctx.FormString("token") != "" || ctx.FormString("access_token") != "" { + ctx.Resp.Header().Set("X-Gitea-Warning", "token and access_token API authentication is deprecated and will be removed in gitea 1.23. Please use AuthorizationHeaderToken instead. Existing queries will continue to work but without authorization.") + } +} + // Routes registers all v1 APIs routes to web application. func Routes() *web.Route { m := web.NewRoute() @@ -817,9 +821,7 @@ func Routes() *web.Route { m.Use(securityHeaders()) if setting.CORSConfig.Enabled { m.Use(cors.Handler(cors.Options{ - // Scheme: setting.CORSConfig.Scheme, // FIXME: the cors middleware needs scheme option - AllowedOrigins: setting.CORSConfig.AllowDomain, - // setting.CORSConfig.AllowSubdomain // FIXME: the cors middleware needs allowSubdomain option + AllowedOrigins: setting.CORSConfig.AllowDomain, AllowedMethods: setting.CORSConfig.Methods, AllowCredentials: setting.CORSConfig.AllowCredentials, AllowedHeaders: append([]string{"Authorization", "X-Gitea-OTP"}, setting.CORSConfig.Headers...), @@ -828,6 +830,8 @@ func Routes() *web.Route { } m.Use(context.APIContexter()) + m.Use(checkDeprecatedAuthMethods) + // Get user from session if logged in. m.Use(apiAuth(buildAuthGroup())) @@ -850,11 +854,11 @@ func Routes() *web.Route { m.Group("/user/{username}", func() { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) - }, context_service.UserAssignmentAPI()) + }, context.UserAssignmentAPI()) m.Group("/user-id/{user-id}", func() { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) - }, context_service.UserIDAssignmentAPI()) + }, context.UserIDAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) } @@ -907,10 +911,10 @@ func Routes() *web.Route { m.Combo("").Get(user.ListAccessTokens). Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken) m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken) - }, reqBasicOrRevProxyAuth()) + }, reqSelfOrAdmin(), reqBasicOrRevProxyAuth()) m.Get("/activities/feeds", user.ListUserActivityFeeds) - }, context_service.UserAssignmentAPI()) + }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) // Users (requires user scope) @@ -928,7 +932,7 @@ func Routes() *web.Route { m.Get("/starred", user.GetStarredRepos) m.Get("/subscriptions", user.GetWatchedRepos) - }, context_service.UserAssignmentAPI()) + }, context.UserAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Users (requires user scope) @@ -943,11 +947,26 @@ func Routes() *web.Route { Post(bind(api.CreateEmailOption{}), user.AddEmail). Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) - // create or update a user's actions secrets - m.Group("/actions/secrets", func() { - m.Combo("/{secretname}"). - Put(bind(api.CreateOrUpdateSecretOption{}), user.CreateOrUpdateSecret). - Delete(repo.DeleteSecret) + // manage user-level actions features + m.Group("/actions", func() { + m.Group("/secrets", func() { + m.Combo("/{secretname}"). + Put(bind(api.CreateOrUpdateSecretOption{}), user.CreateOrUpdateSecret). + Delete(user.DeleteSecret) + }) + + m.Group("/variables", func() { + m.Get("", user.ListVariables) + m.Combo("/{variablename}"). + Get(user.GetVariable). + Delete(user.DeleteVariable). + Post(bind(api.CreateVariableOption{}), user.CreateVariable). + Put(bind(api.UpdateVariableOption{}), user.UpdateVariable) + }) + + m.Group("/runners", func() { + m.Get("/registration-token", reqToken(), user.GetRegistrationToken) + }) }) m.Get("/followers", user.ListMyFollowers) @@ -957,7 +976,7 @@ func Routes() *web.Route { m.Get("", user.CheckMyFollowing) m.Put("", user.Follow) m.Delete("", user.Unfollow) - }, context_service.UserAssignmentAPI()) + }, context.UserAssignmentAPI()) }) // (admin:public_key scope) @@ -1017,7 +1036,16 @@ func Routes() *web.Route { m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) - }, reqToken()) + }) + + m.Group("/blocks", func() { + m.Get("", user.ListBlocks) + m.Group("/{username}", func() { + m.Get("", user.CheckUserBlock) + m.Put("", user.BlockUser) + m.Delete("", user.UnblockUser) + }, context.UserAssignmentAPI()) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1047,10 +1075,25 @@ func Routes() *web.Route { m.Post("/accept", repo.AcceptTransfer) m.Post("/reject", repo.RejectTransfer) }, reqToken()) - m.Group("/actions/secrets", func() { - m.Combo("/{secretname}"). - Put(reqToken(), reqOwner(), bind(api.CreateOrUpdateSecretOption{}), repo.CreateOrUpdateSecret). - Delete(reqToken(), reqOwner(), repo.DeleteSecret) + m.Group("/actions", func() { + m.Group("/secrets", func() { + m.Combo("/{secretname}"). + Put(reqToken(), reqOwner(), bind(api.CreateOrUpdateSecretOption{}), repo.CreateOrUpdateSecret). + Delete(reqToken(), reqOwner(), repo.DeleteSecret) + }) + + m.Group("/variables", func() { + m.Get("", reqToken(), reqOwner(), repo.ListVariables) + m.Combo("/{variablename}"). + Get(reqToken(), reqOwner(), repo.GetVariable). + Delete(reqToken(), reqOwner(), repo.DeleteVariable). + Post(reqToken(), reqOwner(), bind(api.CreateVariableOption{}), repo.CreateVariable). + Put(reqToken(), reqOwner(), bind(api.UpdateVariableOption{}), repo.UpdateVariable) + }) + + m.Group("/runners", func() { + m.Get("/registration-token", reqToken(), reqOwner(), repo.GetRegistrationToken) + }) }) m.Group("/hooks/git", func() { m.Combo("").Get(repo.ListGitHooks) @@ -1095,23 +1138,23 @@ func Routes() *web.Route { m.Group("/branches", func() { m.Get("", repo.ListBranches) m.Get("/*", repo.GetBranch) - m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), repo.DeleteBranch) - m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateBranchRepoOption{}), repo.CreateBranch) + m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch) }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) m.Group("/branch_protections", func() { m.Get("", repo.ListBranchProtections) - m.Post("", bind(api.CreateBranchProtectionOption{}), repo.CreateBranchProtection) + m.Post("", bind(api.CreateBranchProtectionOption{}), mustNotBeArchived, repo.CreateBranchProtection) m.Group("/{name}", func() { m.Get("", repo.GetBranchProtection) - m.Patch("", bind(api.EditBranchProtectionOption{}), repo.EditBranchProtection) + m.Patch("", bind(api.EditBranchProtectionOption{}), mustNotBeArchived, repo.EditBranchProtection) m.Delete("", repo.DeleteBranchProtection) }) }, reqToken(), reqAdmin()) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) - m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateTagOption{}), repo.CreateTag) - m.Delete("/*", reqToken(), repo.DeleteTag) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag) + m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). @@ -1139,9 +1182,9 @@ func Routes() *web.Route { m.Get("/subscribers", repo.ListSubscribers) m.Group("/subscription", func() { m.Get("", user.IsWatching) - m.Put("", reqToken(), user.Watch) - m.Delete("", reqToken(), user.Unwatch) - }) + m.Put("", user.Watch) + m.Delete("", user.Unwatch) + }, reqToken()) m.Group("/releases", func() { m.Combo("").Get(repo.ListReleases). Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), repo.CreateRelease) @@ -1164,13 +1207,13 @@ func Routes() *web.Route { Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) }) }, reqRepoReader(unit.TypeReleases)) - m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), repo.MirrorSync) - m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), repo.PushMirrorSync) + m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) + m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) m.Group("/push_mirrors", func() { m.Combo("").Get(repo.ListPushMirrors). - Post(bind(api.CreatePushMirrorOption{}), repo.AddPushMirror) + Post(mustNotBeArchived, bind(api.CreatePushMirrorOption{}), repo.AddPushMirror) m.Combo("/{name}"). - Delete(repo.DeletePushMirrorByRemoteName). + Delete(mustNotBeArchived, repo.DeletePushMirrorByRemoteName). Get(repo.GetPushMirrorByName) }, reqAdmin(), reqToken()) @@ -1208,6 +1251,7 @@ func Routes() *web.Route { Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests). Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests) }) + m.Get("/{base}/*", repo.GetPullRequestByBaseHead) }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) m.Group("/statuses", func() { m.Combo("/{sha}").Get(repo.GetCommitStatuses). @@ -1218,6 +1262,7 @@ func Routes() *web.Route { m.Group("/{ref}", func() { m.Get("/status", repo.GetCombinedCommitStatusByRef) m.Get("/statuses", repo.GetCommitStatusesByRef) + m.Get("/pull", repo.GetCommitPullRequest) }, context.ReferencesGitRepo()) }, reqRepoReader(unit.TypeCode)) m.Group("/git", func() { @@ -1232,15 +1277,15 @@ func Routes() *web.Route { m.Get("/tags/{sha}", repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) - m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), repo.ApplyDiffPatch) + m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) - m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, repo.ChangeFiles) + m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles) m.Get("/*", repo.GetContents) m.Group("/*", func() { - m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, repo.CreateFile) - m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, repo.UpdateFile) - m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, repo.DeleteFile) + m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile) + m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile) + m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) }, reqToken()) }, reqRepoReader(unit.TypeCode)) m.Get("/signing-key.gpg", misc.SigningKey) @@ -1281,8 +1326,8 @@ func Routes() *web.Route { m.Group("/{username}/{reponame}", func() { m.Group("/issues", func() { m.Combo("").Get(repo.ListIssues). - Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue) - m.Get("/pinned", repo.ListPinnedIssues) + Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue) + m.Get("/pinned", reqRepoReader(unit.TypeIssues), repo.ListPinnedIssues) m.Group("/comments", func() { m.Get("", repo.ListRepoIssueComments) m.Group("/{id}", func() { @@ -1396,14 +1441,14 @@ func Routes() *web.Route { m.Get("/files", reqToken(), packages.ListPackageFiles) }) m.Get("/", reqToken(), packages.ListPackages) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context_service.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead)) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead)) // Organizations m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) m.Group("/users/{username}/orgs", func() { m.Get("", reqToken(), org.ListUserOrgs) m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context_service.UserAssignmentAPI()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI()) m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization)) m.Group("/orgs/{org}", func() { @@ -1417,11 +1462,26 @@ func Routes() *web.Route { m.Combo("/{username}").Get(reqToken(), org.IsMember). Delete(reqToken(), reqOrgOwnership(), org.DeleteMember) }) - m.Group("/actions/secrets", func() { - m.Get("", reqToken(), reqOrgOwnership(), org.ListActionsSecrets) - m.Combo("/{secretname}"). - Put(reqToken(), reqOrgOwnership(), bind(api.CreateOrUpdateSecretOption{}), org.CreateOrUpdateSecret). - Delete(reqToken(), reqOrgOwnership(), org.DeleteSecret) + m.Group("/actions", func() { + m.Group("/secrets", func() { + m.Get("", reqToken(), reqOrgOwnership(), org.ListActionsSecrets) + m.Combo("/{secretname}"). + Put(reqToken(), reqOrgOwnership(), bind(api.CreateOrUpdateSecretOption{}), org.CreateOrUpdateSecret). + Delete(reqToken(), reqOrgOwnership(), org.DeleteSecret) + }) + + m.Group("/variables", func() { + m.Get("", reqToken(), reqOrgOwnership(), org.ListVariables) + m.Combo("/{variablename}"). + Get(reqToken(), reqOrgOwnership(), org.GetVariable). + Delete(reqToken(), reqOrgOwnership(), org.DeleteVariable). + Post(reqToken(), reqOrgOwnership(), bind(api.CreateVariableOption{}), org.CreateVariable). + Put(reqToken(), reqOrgOwnership(), bind(api.UpdateVariableOption{}), org.UpdateVariable) + }) + + m.Group("/runners", func() { + m.Get("/registration-token", reqToken(), reqOrgOwnership(), org.GetRegistrationToken) + }) }) m.Group("/public_members", func() { m.Get("", org.ListPublicMembers) @@ -1430,10 +1490,10 @@ func Routes() *web.Route { Delete(reqToken(), reqOrgMembership(), org.ConcealMember) }) m.Group("/teams", func() { - m.Get("", reqToken(), org.ListTeams) - m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) - m.Get("/search", reqToken(), org.SearchTeam) - }, reqOrgMembership()) + m.Get("", org.ListTeams) + m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) + m.Get("/search", org.SearchTeam) + }, reqToken(), reqOrgMembership()) m.Group("/labels", func() { m.Get("", org.ListLabels) m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel) @@ -1453,6 +1513,15 @@ func Routes() *web.Route { m.Delete("", org.DeleteAvatar) }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) + + m.Group("/blocks", func() { + m.Get("", org.ListBlocks) + m.Group("/{username}", func() { + m.Get("", org.CheckUserBlock) + m.Put("", org.BlockUser) + m.Delete("", org.UnblockUser) + }) + }, reqToken(), reqOrgOwnership()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(), org.GetTeam). @@ -1495,7 +1564,10 @@ func Routes() *web.Route { m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) - }, context_service.UserAssignmentAPI()) + m.Get("/badges", admin.ListUserBadges) + m.Post("/badges", bind(api.UserBadgeOption{}), admin.AddUserBadges) + m.Delete("/badges", bind(api.UserBadgeOption{}), admin.DeleteUserBadges) + }, context.UserAssignmentAPI()) }) m.Group("/emails", func() { m.Get("", admin.GetAllEmails) @@ -1513,6 +1585,9 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), admin.EditHook). Delete(admin.DeleteHook) }) + m.Group("/runners", func() { + m.Get("/registration-token", admin.GetRegistrationToken) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) m.Group("/topics", func() { diff --git a/routers/api/v1/auth.go b/routers/api/v1/auth.go deleted file mode 100644 index e44271ba148..00000000000 --- a/routers/api/v1/auth.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build !windows - -package v1 - -import auth_service "code.gitea.io/gitea/services/auth" - -func specialAdd(group *auth_service.Group) {} diff --git a/routers/api/v1/auth_windows.go b/routers/api/v1/auth_windows.go deleted file mode 100644 index 3514e21baac..00000000000 --- a/routers/api/v1/auth_windows.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package v1 - -import ( - "code.gitea.io/gitea/models/auth" - auth_service "code.gitea.io/gitea/services/auth" -) - -// specialAdd registers the SSPI auth method as the last method in the list. -// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation -// fails (or if negotiation should continue), which would prevent other authentication methods -// to execute at all. -func specialAdd(group *auth_service.Group) { - if auth.IsSSPIEnabled() { - group.Add(&auth_service.SSPI{}) - } -} diff --git a/routers/api/v1/misc/gitignore.go b/routers/api/v1/misc/gitignore.go index 7c7fe4b125f..dffd771752e 100644 --- a/routers/api/v1/misc/gitignore.go +++ b/routers/api/v1/misc/gitignore.go @@ -6,11 +6,11 @@ package misc import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/options" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) // Shows a list of all Gitignore templates diff --git a/routers/api/v1/misc/label_templates.go b/routers/api/v1/misc/label_templates.go index 0e0ca39fc5d..cc11f376262 100644 --- a/routers/api/v1/misc/label_templates.go +++ b/routers/api/v1/misc/label_templates.go @@ -6,9 +6,9 @@ package misc import ( "net/http" - "code.gitea.io/gitea/modules/context" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/api/v1/misc/licenses.go b/routers/api/v1/misc/licenses.go index 65f63468cfd..2a980f5084d 100644 --- a/routers/api/v1/misc/licenses.go +++ b/routers/api/v1/misc/licenses.go @@ -8,12 +8,12 @@ import ( "net/http" "net/url" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/options" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) // Returns a list of all License templates diff --git a/routers/api/v1/misc/markup.go b/routers/api/v1/misc/markup.go index 7b24b353b63..9699c79368a 100644 --- a/routers/api/v1/misc/markup.go +++ b/routers/api/v1/misc/markup.go @@ -6,12 +6,12 @@ package misc import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" ) // Markup render markup document to HTML diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index ec8f8f47b70..5236fd06ae2 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -10,19 +10,19 @@ import ( "strings" "testing" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) const ( - AppURL = "http://localhost:3000/" - Repo = "gogits/gogs" - AppSubURL = AppURL + Repo + "/" + AppURL = "http://localhost:3000/" + Repo = "gogits/gogs" + FullURL = AppURL + Repo + "/" ) func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, responseCode int) { @@ -74,20 +74,20 @@ func TestAPI_RenderGFM(t *testing.T) { // rendered `

Wiki! Enjoy :)

`, // Guard wiki sidebar: special syntax `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, // rendered - `

Guardfile-DSL / Configuring-Guard

+ `

Guardfile-DSL / Configuring-Guard

`, // special syntax `[[Name|Link]]`, // rendered - `

Name

+ `

Name

`, // empty ``, @@ -111,8 +111,8 @@ Here are some links to the most important topics. You can find the full list of

Wine Staging on website wine-staging.com.

Here are some links to the most important topics. You can find the full list of pages at the sidebar.

-

Configuration -images/icon-bug.png

+

Configuration +images/icon-bug.png

`, } diff --git a/routers/api/v1/misc/nodeinfo.go b/routers/api/v1/misc/nodeinfo.go index 22004a8a884..3bd80de5c18 100644 --- a/routers/api/v1/misc/nodeinfo.go +++ b/routers/api/v1/misc/nodeinfo.go @@ -9,9 +9,9 @@ import ( issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" ) const cacheKeyNodeInfoUsage = "API_NodeInfoUsage" @@ -29,10 +29,9 @@ func NodeInfo(ctx *context.APIContext) { nodeInfoUsage := structs.NodeInfoUsage{} if setting.Federation.ShareUserStatistics { - cached := false - if setting.CacheService.Enabled { - nodeInfoUsage, cached = ctx.Cache.Get(cacheKeyNodeInfoUsage).(structs.NodeInfoUsage) - } + var cached bool + nodeInfoUsage, cached = ctx.Cache.Get(cacheKeyNodeInfoUsage).(structs.NodeInfoUsage) + if !cached { usersTotal := int(user_model.CountUsers(ctx, nil)) now := time.Now() @@ -42,7 +41,7 @@ func NodeInfo(ctx *context.APIContext) { usersActiveHalfyear := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeHaveYearAgo})) allIssues, _ := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{}) - allComments, _ := issues_model.CountComments(&issues_model.FindCommentsOptions{}) + allComments, _ := issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{}) nodeInfoUsage = structs.NodeInfoUsage{ Users: structs.NodeInfoUsageUsers{ @@ -53,11 +52,10 @@ func NodeInfo(ctx *context.APIContext) { LocalPosts: int(allIssues), LocalComments: int(allComments), } - if setting.CacheService.Enabled { - if err := ctx.Cache.Put(cacheKeyNodeInfoUsage, nodeInfoUsage, 180); err != nil { - ctx.InternalServerError(err) - return - } + + if err := ctx.Cache.Put(cacheKeyNodeInfoUsage, nodeInfoUsage, 180); err != nil { + ctx.InternalServerError(err) + return } } } diff --git a/routers/api/v1/misc/signing.go b/routers/api/v1/misc/signing.go index 2ca9813e15c..24a46c1e704 100644 --- a/routers/api/v1/misc/signing.go +++ b/routers/api/v1/misc/signing.go @@ -7,8 +7,8 @@ import ( "fmt" "net/http" - "code.gitea.io/gitea/modules/context" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" ) // SigningKey returns the public key of the default signing key if it exists diff --git a/routers/api/v1/misc/version.go b/routers/api/v1/misc/version.go index 83fa35219ab..e3b43a0e6b6 100644 --- a/routers/api/v1/misc/version.go +++ b/routers/api/v1/misc/version.go @@ -6,9 +6,9 @@ package misc import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" ) // Version shows the version of the Gitea server diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go index b22ea8a7715..46b3c7f5e75 100644 --- a/routers/api/v1/notify/notifications.go +++ b/routers/api/v1/notify/notifications.go @@ -8,9 +8,10 @@ import ( "strings" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" ) // NewAvailable check if unread notifications exist @@ -21,7 +22,17 @@ func NewAvailable(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/NotificationCount" - ctx.JSON(http.StatusOK, api.NotificationCount{New: activities_model.CountUnread(ctx, ctx.Doer.ID)}) + + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "db.Count[activities_model.Notification]", err) + return + } + + ctx.JSON(http.StatusOK, api.NotificationCount{New: total}) } func getFindNotificationOptions(ctx *context.APIContext) *activities_model.FindNotificationOptions { diff --git a/routers/api/v1/notify/repo.go b/routers/api/v1/notify/repo.go index e16c54a2c0b..1744426ee8e 100644 --- a/routers/api/v1/notify/repo.go +++ b/routers/api/v1/notify/repo.go @@ -9,9 +9,9 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -108,18 +108,18 @@ func ListRepoNotifications(ctx *context.APIContext) { } opts.RepoID = ctx.Repo.Repository.ID - totalCount, err := activities_model.CountNotifications(ctx, opts) + totalCount, err := db.Count[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return } - nl, err := activities_model.GetNotifications(ctx, opts) + nl, err := db.Find[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return } - err = nl.LoadAttributes(ctx) + err = activities_model.NotificationList(nl).LoadAttributes(ctx) if err != nil { ctx.InternalServerError(err) return @@ -127,7 +127,7 @@ func ListRepoNotifications(ctx *context.APIContext) { ctx.SetTotalCountHeader(totalCount) - ctx.JSON(http.StatusOK, convert.ToNotifications(nl)) + ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl)) } // ReadRepoNotifications mark notification threads as read on a specific repo @@ -200,9 +200,8 @@ func ReadRepoNotifications(ctx *context.APIContext) { if !ctx.FormBool("all") { statuses := ctx.FormStrings("status-types") opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"}) - log.Error("%v", opts.Status) } - nl, err := activities_model.GetNotifications(ctx, opts) + nl, err := db.Find[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -222,7 +221,7 @@ func ReadRepoNotifications(ctx *context.APIContext) { return } _ = notif.LoadAttributes(ctx) - changed = append(changed, convert.ToNotificationThread(notif)) + changed = append(changed, convert.ToNotificationThread(ctx, notif)) } ctx.JSON(http.StatusResetContent, changed) } diff --git a/routers/api/v1/notify/threads.go b/routers/api/v1/notify/threads.go index 6a1bce4de41..8e12d359cb3 100644 --- a/routers/api/v1/notify/threads.go +++ b/routers/api/v1/notify/threads.go @@ -10,7 +10,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -46,7 +46,7 @@ func GetThread(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToNotificationThread(n)) + ctx.JSON(http.StatusOK, convert.ToNotificationThread(ctx, n)) } // ReadThread mark notification as read by ID @@ -97,7 +97,7 @@ func ReadThread(ctx *context.APIContext) { ctx.InternalServerError(err) return } - ctx.JSON(http.StatusResetContent, convert.ToNotificationThread(notif)) + ctx.JSON(http.StatusResetContent, convert.ToNotificationThread(ctx, notif)) } func getThread(ctx *context.APIContext) *activities_model.Notification { diff --git a/routers/api/v1/notify/user.go b/routers/api/v1/notify/user.go index a9c6b436179..879f484ccee 100644 --- a/routers/api/v1/notify/user.go +++ b/routers/api/v1/notify/user.go @@ -8,8 +8,9 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -68,25 +69,25 @@ func ListNotifications(ctx *context.APIContext) { return } - totalCount, err := activities_model.CountNotifications(ctx, opts) + totalCount, err := db.Count[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return } - nl, err := activities_model.GetNotifications(ctx, opts) + nl, err := db.Find[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return } - err = nl.LoadAttributes(ctx) + err = activities_model.NotificationList(nl).LoadAttributes(ctx) if err != nil { ctx.InternalServerError(err) return } ctx.SetTotalCountHeader(totalCount) - ctx.JSON(http.StatusOK, convert.ToNotifications(nl)) + ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl)) } // ReadNotifications mark notification threads as read, unread, or pinned @@ -147,7 +148,7 @@ func ReadNotifications(ctx *context.APIContext) { statuses := ctx.FormStrings("status-types") opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"}) } - nl, err := activities_model.GetNotifications(ctx, opts) + nl, err := db.Find[activities_model.Notification](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -167,7 +168,7 @@ func ReadNotifications(ctx *context.APIContext) { return } _ = notif.LoadAttributes(ctx) - changed = append(changed, convert.ToNotificationThread(notif)) + changed = append(changed, convert.ToNotificationThread(ctx, notif)) } ctx.JSON(http.StatusResetContent, changed) diff --git a/routers/api/v1/org/avatar.go b/routers/api/v1/org/avatar.go index a7b50085252..e34c68dfc9d 100644 --- a/routers/api/v1/org/avatar.go +++ b/routers/api/v1/org/avatar.go @@ -7,9 +7,9 @@ import ( "encoding/base64" "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" user_service "code.gitea.io/gitea/services/user" ) @@ -43,7 +43,7 @@ func UpdateAvatar(ctx *context.APIContext) { return } - err = user_service.UploadAvatar(ctx.Org.Organization.AsUser(), content) + err = user_service.UploadAvatar(ctx, ctx.Org.Organization.AsUser(), content) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) } @@ -69,7 +69,7 @@ func DeleteAvatar(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" - err := user_service.DeleteAvatar(ctx.Org.Organization.AsUser()) + err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) } diff --git a/routers/api/v1/org/block.go b/routers/api/v1/org/block.go new file mode 100644 index 00000000000..69a5222a20a --- /dev/null +++ b/routers/api/v1/org/block.go @@ -0,0 +1,116 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package org + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +func ListBlocks(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/blocks organization organizationListBlocks + // --- + // summary: List users blocked by the organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserList" + + shared.ListBlocks(ctx, ctx.Org.Organization.AsUser()) +} + +func CheckUserBlock(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/blocks/{username} organization organizationCheckUserBlock + // --- + // summary: Check if a user is blocked by the organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to check + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + shared.CheckUserBlock(ctx, ctx.Org.Organization.AsUser()) +} + +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/blocks/{username} organization organizationBlockUser + // --- + // summary: Block a user + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to block + // type: string + // required: true + // - name: note + // in: query + // description: optional note for the block + // type: string + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.BlockUser(ctx, ctx.Org.Organization.AsUser()) +} + +func UnblockUser(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/blocks/{username} organization organizationUnblockUser + // --- + // summary: Unblock a user + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to unblock + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.UnblockUser(ctx, ctx.Doer, ctx.Org.Organization.AsUser()) +} diff --git a/routers/api/v1/org/hook.go b/routers/api/v1/org/hook.go index 3c3f058b5dd..c1dc0519ea1 100644 --- a/routers/api/v1/org/hook.go +++ b/routers/api/v1/org/hook.go @@ -6,10 +6,10 @@ package org import ( "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" ) diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go index 2dd4505a91d..b5ec54ccf4e 100644 --- a/routers/api/v1/org/label.go +++ b/routers/api/v1/org/label.go @@ -9,11 +9,11 @@ import ( "strings" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/label" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -50,7 +50,7 @@ func ListLabels(ctx *context.APIContext) { return } - count, err := issues_model.CountLabelsByOrgID(ctx.Org.Organization.ID) + count, err := issues_model.CountLabelsByOrgID(ctx, ctx.Org.Organization.ID) if err != nil { ctx.InternalServerError(err) return @@ -218,7 +218,7 @@ func EditLabel(ctx *context.APIContext) { l.Description = *form.Description } l.SetArchived(form.IsArchived != nil && *form.IsArchived) - if err := issues_model.UpdateLabel(l); err != nil { + if err := issues_model.UpdateLabel(ctx, l); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) return } @@ -249,7 +249,7 @@ func DeleteLabel(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := issues_model.DeleteLabel(ctx.Org.Organization.ID, ctx.ParamsInt64(":id")); err != nil { + if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.ParamsInt64(":id")); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteLabel", err) return } diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index 67956e66bd2..9db9ad964b6 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -9,11 +9,11 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -25,7 +25,7 @@ func listMembers(ctx *context.APIContext, publicOnly bool) { ListOptions: utils.GetListOptions(ctx), } - count, err := organization.CountOrgMembers(opts) + count, err := organization.CountOrgMembers(ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -75,7 +75,7 @@ func ListMembers(ctx *context.APIContext) { publicOnly := true if ctx.Doer != nil { - isMember, err := ctx.Org.Organization.IsOrgMember(ctx.Doer.ID) + isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) return @@ -144,12 +144,12 @@ func IsMember(ctx *context.APIContext) { return } if ctx.Doer != nil { - userIsMember, err := ctx.Org.Organization.IsOrgMember(ctx.Doer.ID) + userIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) return } else if userIsMember || ctx.Doer.IsAdmin { - userToCheckIsMember, err := ctx.Org.Organization.IsOrgMember(userToCheck.ID) + userToCheckIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, userToCheck.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) } else if userToCheckIsMember { @@ -194,7 +194,7 @@ func IsPublicMember(ctx *context.APIContext) { if ctx.Written() { return } - is, err := organization.IsPublicMembership(ctx.Org.Organization.ID, userToCheck.ID) + is, err := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, userToCheck.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsPublicMembership", err) return @@ -240,7 +240,7 @@ func PublicizeMember(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Cannot publicize another member") return } - err := organization.ChangeOrgUserStatus(ctx.Org.Organization.ID, userToPublicize.ID, true) + err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToPublicize.ID, true) if err != nil { ctx.Error(http.StatusInternalServerError, "ChangeOrgUserStatus", err) return @@ -282,7 +282,7 @@ func ConcealMember(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Cannot conceal another member") return } - err := organization.ChangeOrgUserStatus(ctx.Org.Organization.ID, userToConceal.ID, false) + err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToConceal.ID, false) if err != nil { ctx.Error(http.StatusInternalServerError, "ChangeOrgUserStatus", err) return @@ -318,7 +318,7 @@ func DeleteMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.RemoveOrgUser(ctx.Org.Organization.ID, member.ID); err != nil { + if err := models.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveOrgUser", err) } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index c5f531923b6..e848d951810 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -12,13 +12,15 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/org" + user_service "code.gitea.io/gitea/services/user" ) func listUserOrgs(ctx *context.APIContext, u *user_model.User) { @@ -30,14 +32,9 @@ func listUserOrgs(ctx *context.APIContext, u *user_model.User) { UserID: u.ID, IncludePrivate: showPrivate, } - orgs, err := organization.FindOrgs(opts) + orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { - ctx.Error(http.StatusInternalServerError, "FindOrgs", err) - return - } - maxResults, err := organization.CountOrgs(opts) - if err != nil { - ctx.Error(http.StatusInternalServerError, "CountOrgs", err) + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[organization.Organization]", err) return } @@ -145,7 +142,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) { } org := organization.OrgFromUser(o) - authorizeLevel, err := org.GetOrgUserMaxAuthorizeLevel(ctx.ContextUser.ID) + authorizeLevel, err := org.GetOrgUserMaxAuthorizeLevel(ctx, ctx.ContextUser.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "GetOrgUserAuthorizeLevel", err) return @@ -164,7 +161,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) { op.IsOwner = true } - op.CanCreateRepository, err = org.CanCreateOrgRepo(ctx.ContextUser.ID) + op.CanCreateRepository, err = org.CanCreateOrgRepo(ctx, ctx.ContextUser.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err) return @@ -268,7 +265,7 @@ func Create(ctx *context.APIContext) { Visibility: visibility, RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, } - if err := organization.CreateOrganization(org, ctx.Doer); err != nil { + if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil { if user_model.IsErrUserAlreadyExist(err) || db.IsErrNameReserved(err) || db.IsErrNameCharsNotAllowed(err) || @@ -342,28 +339,30 @@ func Edit(ctx *context.APIContext) { // "$ref": "#/responses/Organization" // "404": // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditOrgOption) - org := ctx.Org.Organization - org.FullName = form.FullName - org.Email = form.Email - org.Description = form.Description - org.Website = form.Website - org.Location = form.Location - if form.Visibility != "" { - org.Visibility = api.VisibilityModes[form.Visibility] + + if form.Email != "" { + if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Org.Organization.AsUser(), form.Email); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplacePrimaryEmailAddress", err) + return + } } - if form.RepoAdminChangeTeamAccess != nil { - org.RepoAdminChangeTeamAccess = *form.RepoAdminChangeTeamAccess + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Description: optional.Some(form.Description), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), + RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), } - if err := user_model.UpdateUserCols(ctx, org.AsUser(), - "full_name", "description", "website", "location", - "visibility", "repo_admin_change_team_access", - ); err != nil { - ctx.Error(http.StatusInternalServerError, "EditOrganization", err) + if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateUser", err) return } - ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, org)) + ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, ctx.Org.Organization)) } // Delete an organization @@ -385,7 +384,7 @@ func Delete(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := org.DeleteOrganization(ctx.Org.Organization); err != nil { + if err := org.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteOrganization", err) return } @@ -429,7 +428,7 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { includePrivate = true } else { org := organization.OrgFromUser(ctx.ContextUser) - isMember, err := org.IsOrgMember(ctx.Doer.ID) + isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) return diff --git a/routers/api/v1/org/runners.go b/routers/api/v1/org/runners.go new file mode 100644 index 00000000000..2a52bd8778f --- /dev/null +++ b/routers/api/v1/org/runners.go @@ -0,0 +1,31 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization + +// GetRegistrationToken returns the token to register org runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/runners/registration-token organization orgGetRunnerRegistrationToken + // --- + // summary: Get an organization's actions runner registration token + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0) +} diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/secrets.go similarity index 95% rename from routers/api/v1/org/action.go rename to routers/api/v1/org/secrets.go index 5af61257735..abb6bb26c43 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/secrets.go @@ -7,12 +7,13 @@ import ( "errors" "net/http" + "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -48,13 +49,7 @@ func ListActionsSecrets(ctx *context.APIContext) { ListOptions: utils.GetListOptions(ctx), } - count, err := secret_model.CountSecrets(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - secrets, err := secret_model.FindSecrets(ctx, *opts) + secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts) if err != nil { ctx.InternalServerError(err) return diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 519572ee518..015af774e34 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -15,12 +15,13 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" @@ -53,7 +54,7 @@ func ListTeams(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - teams, count, err := organization.SearchTeam(&organization.SearchTeamOptions{ + teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ ListOptions: utils.GetListOptions(ctx), OrgID: ctx.Org.Organization.ID, }) @@ -92,7 +93,7 @@ func ListUserTeams(ctx *context.APIContext) { // "200": // "$ref": "#/responses/TeamList" - teams, count, err := organization.SearchTeam(&organization.SearchTeamOptions{ + teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ ListOptions: utils.GetListOptions(ctx), UserID: ctx.Doer.ID, }) @@ -248,7 +249,7 @@ func CreateTeam(ctx *context.APIContext) { return } - apiTeam, err := convert.ToTeam(ctx, team) + apiTeam, err := convert.ToTeam(ctx, team, true) if err != nil { ctx.InternalServerError(err) return @@ -486,6 +487,8 @@ func AddTeamMember(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" @@ -493,8 +496,12 @@ func AddTeamMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.AddTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { - ctx.Error(http.StatusInternalServerError, "AddMember", err) + if err := models.AddTeamMember(ctx, ctx.Org.Team, u); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "AddTeamMember", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddTeamMember", err) + } return } ctx.Status(http.StatusNoContent) @@ -530,7 +537,7 @@ func RemoveTeamMember(ctx *context.APIContext) { return } - if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { + if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err) return } @@ -638,7 +645,7 @@ func GetTeamRepo(ctx *context.APIContext) { // getRepositoryByParams get repository by a team's organization ID and repo name func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository { - repo, err := repo_model.GetRepositoryByName(ctx.Org.Team.OrgID, ctx.Params(":reponame")) + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Team.OrgID, ctx.Params(":reponame")) if err != nil { if repo_model.IsErrRepoNotExist(err) { ctx.NotFound() @@ -810,7 +817,7 @@ func SearchTeam(ctx *context.APIContext) { opts.UserID = ctx.Doer.ID } - teams, maxResults, err := organization.SearchTeam(opts) + teams, maxResults, err := organization.SearchTeam(ctx, opts) if err != nil { log.Error("SearchTeam failed: %v", err) ctx.JSON(http.StatusInternalServerError, map[string]any{ diff --git a/routers/api/v1/org/variables.go b/routers/api/v1/org/variables.go new file mode 100644 index 00000000000..eaf7bdc45ba --- /dev/null +++ b/routers/api/v1/org/variables.go @@ -0,0 +1,291 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" +) + +// ListVariables list org-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList + // --- + // summary: Get an org-level variables list + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} + +// GetVariable get an org-level variable +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable + // --- + // summary: Get an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete an org-level variable +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/actions/variables/{variablename} organization deleteOrgVariable + // --- + // summary: Delete an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create an org-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/actions/variables/{variablename} organization createOrgVariable + // --- + // summary: Create an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating an org-level variable + // "204": + // description: response when creating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Org.Organization.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update an org-level variable +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/actions/variables/{variablename} organization updateOrgVariable + // --- + // summary: Update an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating an org-level variable + // "204": + // description: response when updating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index 53724179a2c..b38aa131676 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -7,10 +7,10 @@ import ( "net/http" "code.gitea.io/gitea/models/packages" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" packages_service "code.gitea.io/gitea/services/packages" ) @@ -60,7 +60,7 @@ func ListPackages(ctx *context.APIContext) { OwnerID: ctx.Package.Owner.ID, Type: packages.Type(packageType), Name: packages.SearchValue{Value: query}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: &listOptions, }) if err != nil { @@ -164,7 +164,7 @@ func DeletePackage(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := packages_service.RemovePackageVersion(ctx.Doer, ctx.Package.Descriptor.Version) + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) if err != nil { ctx.Error(http.StatusInternalServerError, "RemovePackageVersion", err) return diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 039cdadac9c..03321d956d7 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -7,10 +7,14 @@ import ( "errors" "net/http" - "code.gitea.io/gitea/modules/context" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -127,3 +131,295 @@ func DeleteSecret(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// GetVariable get a repo-level variable +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables/{variablename} repository getRepoVariable + // --- + // summary: Get a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete a repo-level variable +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/variables/{variablename} repository deleteRepoVariable + // --- + // summary: Delete a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, 0, ctx.Repo.Repository.ID, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create a repo-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/variables/{variablename} repository createRepoVariable + // --- + // summary: Create a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating a repo-level variable + // "204": + // description: response when creating a repo-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + repoID := ctx.Repo.Repository.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: repoID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, 0, repoID, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a repo-level variable +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/variables/{variablename} repository updateRepoVariable + // --- + // summary: Update a repo-level variable + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a repo-level variable + // "204": + // description: response when updating a repo-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListVariables list repo-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables repository getRepoVariablesList + // --- + // summary: Get repo-level variables list + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} diff --git a/routers/api/v1/repo/avatar.go b/routers/api/v1/repo/avatar.go index 1b661955f05..698337ffd2f 100644 --- a/routers/api/v1/repo/avatar.go +++ b/routers/api/v1/repo/avatar.go @@ -7,9 +7,9 @@ import ( "encoding/base64" "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go index 26605bba03c..3b116666ead 100644 --- a/routers/api/v1/repo/blob.go +++ b/routers/api/v1/repo/blob.go @@ -6,7 +6,7 @@ package repo import ( "net/http" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" files_service "code.gitea.io/gitea/services/repository/files" ) diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index c851525c0f0..5e6b6a86586 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -10,16 +10,18 @@ import ( "net/http" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" @@ -117,17 +119,13 @@ func DeleteBranch(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" - + // "423": + // "$ref": "#/responses/repoArchivedError" if ctx.Repo.Repository.IsEmpty { ctx.Error(http.StatusNotFound, "", "Git Repository is empty.") return } - if ctx.Repo.Repository.IsArchived { - ctx.Error(http.StatusForbidden, "", "Git Repository is archived.") - return - } - if ctx.Repo.Repository.IsMirror { ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.") return @@ -141,9 +139,9 @@ func DeleteBranch(ctx *context.APIContext) { } // check whether branches of this repository has been synced - totalNumOfBranches, err := git_model.CountBranches(ctx, git_model.FindBranchOptions{ + totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{ RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: util.OptionalBoolFalse, + IsDeletedBranch: optional.Some(false), }) if err != nil { ctx.Error(http.StatusInternalServerError, "CountBranches", err) @@ -157,10 +155,6 @@ func DeleteBranch(ctx *context.APIContext) { } } - if ctx.Repo.Repository.IsArchived { - ctx.Error(http.StatusForbidden, "IsArchived", fmt.Errorf("can not delete branch of an archived repository")) - return - } if ctx.Repo.Repository.IsMirror { ctx.Error(http.StatusForbidden, "IsMirrored", fmt.Errorf("can not delete branch of an mirror repository")) return @@ -216,17 +210,14 @@ func CreateBranch(ctx *context.APIContext) { // description: The old branch does not exist. // "409": // description: The branch with the same name already exists. + // "423": + // "$ref": "#/responses/repoArchivedError" if ctx.Repo.Repository.IsEmpty { ctx.Error(http.StatusNotFound, "", "Git Repository is empty.") return } - if ctx.Repo.Repository.IsArchived { - ctx.Error(http.StatusForbidden, "", "Git Repository is archived.") - return - } - if ctx.Repo.Repository.IsMirror { ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.") return @@ -262,12 +253,11 @@ func CreateBranch(ctx *context.APIContext) { } } - err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, oldCommit.ID.String(), opt.BranchName) + err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, oldCommit.ID.String(), opt.BranchName) if err != nil { if git_model.IsErrBranchNotExist(err) { ctx.Error(http.StatusNotFound, "", "The old branch does not exist") - } - if models.IsErrTagAlreadyExists(err) { + } else if models.IsErrTagAlreadyExists(err) { ctx.Error(http.StatusConflict, "", "The branch with the same tag already exists.") } else if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { ctx.Error(http.StatusConflict, "", "The branch already exists.") @@ -350,10 +340,10 @@ func ListBranches(ctx *context.APIContext) { branchOpts := git_model.FindBranchOptions{ ListOptions: listOptions, RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: util.OptionalBoolFalse, + IsDeletedBranch: optional.Some(false), } var err error - totalNumOfBranches, err = git_model.CountBranches(ctx, branchOpts) + totalNumOfBranches, err = db.Count[git_model.Branch](ctx, branchOpts) if err != nil { ctx.Error(http.StatusInternalServerError, "CountBranches", err) return @@ -372,7 +362,7 @@ func ListBranches(ctx *context.APIContext) { return } - branches, err := git_model.FindBranches(ctx, branchOpts) + branches, err := db.Find[git_model.Branch](ctx, branchOpts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetBranches", err) return @@ -519,6 +509,8 @@ func CreateBranchProtection(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateBranchProtectionOption) repo := ctx.Repo.Repository @@ -581,7 +573,7 @@ func CreateBranchProtection(ctx *context.APIContext) { } var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 if repo.Owner.IsOrganization() { - whitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.PushWhitelistTeams, false) + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -590,7 +582,7 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) return } - mergeWhitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.MergeWhitelistTeams, false) + mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -599,7 +591,7 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) return } - approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.ApprovalsWhitelistTeams, false) + approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -624,6 +616,7 @@ func CreateBranchProtection(ctx *context.APIContext) { BlockOnRejectedReviews: form.BlockOnRejectedReviews, BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests, DismissStaleApprovals: form.DismissStaleApprovals, + IgnoreStaleApprovals: form.IgnoreStaleApprovals, RequireSignedCommits: form.RequireSignedCommits, ProtectedFilePatterns: form.ProtectedFilePatterns, UnprotectedFilePatterns: form.UnprotectedFilePatterns, @@ -651,7 +644,7 @@ func CreateBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return @@ -727,6 +720,8 @@ func EditBranchProtection(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.EditBranchProtectionOption) repo := ctx.Repo.Repository bpName := ctx.Params(":name") @@ -793,6 +788,10 @@ func EditBranchProtection(ctx *context.APIContext) { protectBranch.DismissStaleApprovals = *form.DismissStaleApprovals } + if form.IgnoreStaleApprovals != nil { + protectBranch.IgnoreStaleApprovals = *form.IgnoreStaleApprovals + } + if form.RequireSignedCommits != nil { protectBranch.RequireSignedCommits = *form.RequireSignedCommits } @@ -855,7 +854,7 @@ func EditBranchProtection(ctx *context.APIContext) { var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 if repo.Owner.IsOrganization() { if form.PushWhitelistTeams != nil { - whitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.PushWhitelistTeams, false) + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -868,7 +867,7 @@ func EditBranchProtection(ctx *context.APIContext) { whitelistTeams = protectBranch.WhitelistTeamIDs } if form.MergeWhitelistTeams != nil { - mergeWhitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.MergeWhitelistTeams, false) + mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -881,7 +880,7 @@ func EditBranchProtection(ctx *context.APIContext) { mergeWhitelistTeams = protectBranch.MergeWhitelistTeamIDs } if form.ApprovalsWhitelistTeams != nil { - approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(repo.OwnerID, form.ApprovalsWhitelistTeams, false) + approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) @@ -922,7 +921,7 @@ func EditBranchProtection(ctx *context.APIContext) { } else { if !isPlainRule { if ctx.Repo.GitRepo == nil { - ctx.Repo.GitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return @@ -1004,7 +1003,7 @@ func DeleteBranchProtection(ctx *context.APIContext) { return } - if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository.ID, bp.ID); err != nil { + if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, bp.ID); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteProtectedBranch", err) return } diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index 4be43d46adf..4ce14f7d018 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -12,11 +12,11 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" repo_module "code.gitea.io/gitea/modules/repository" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" repo_service "code.gitea.io/gitea/services/repository" ) @@ -53,13 +53,10 @@ func ListCollaborators(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - count, err := repo_model.CountCollaborators(ctx.Repo.Repository.ID) - if err != nil { - ctx.InternalServerError(err) - return - } - - collaborators, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) + collaborators, total, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + }) if err != nil { ctx.Error(http.StatusInternalServerError, "ListCollaborators", err) return @@ -70,7 +67,7 @@ func ListCollaborators(ctx *context.APIContext) { users[i] = convert.ToUser(ctx, collaborator.User, ctx.Doer) } - ctx.SetTotalCountHeader(count) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, users) } @@ -156,6 +153,8 @@ func AddCollaborator(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": @@ -179,7 +178,11 @@ func AddCollaborator(ctx *context.APIContext) { } if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil { - ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "AddCollaborator", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + } return } @@ -234,7 +237,7 @@ func DeleteCollaborator(ctx *context.APIContext) { return } - if err := repo_service.DeleteCollaboration(ctx.Repo.Repository, collaborator.ID); err != nil { + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteCollaboration", err) return } diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go index 20d4405d6d4..d06a3b4e49a 100644 --- a/routers/api/v1/repo/commits.go +++ b/routers/api/v1/repo/commits.go @@ -10,12 +10,13 @@ import ( "net/http" "strconv" + issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -205,7 +206,6 @@ func GetAllCommits(ctx *context.APIContext) { Not: not, Revision: []string{baseCommit.ID.String()}, }) - if err != nil { ctx.Error(http.StatusInternalServerError, "GetCommitsCount", err) return @@ -245,7 +245,6 @@ func GetAllCommits(ctx *context.APIContext) { Not: not, Page: listOptions.Page, }) - if err != nil { ctx.Error(http.StatusInternalServerError, "CommitsByFileAndRange", err) return @@ -325,3 +324,53 @@ func DownloadCommitDiffOrPatch(ctx *context.APIContext) { return } } + +// GetCommitPullRequest returns the pull request of the commit +func GetCommitPullRequest(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits/{sha}/pull repository repoGetCommitPullRequest + // --- + // summary: Get the pull request of the commit + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: sha + // in: path + // description: SHA of the commit to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, ctx.Params(":sha")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.Error(http.StatusNotFound, "GetPullRequestByMergedCommit", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index ea311c32029..156033f58aa 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -19,8 +19,8 @@ import ( git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" @@ -29,6 +29,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" archiver_service "code.gitea.io/gitea/services/repository/archiver" files_service "code.gitea.io/gitea/services/repository/files" ) @@ -144,7 +145,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { return } - // OK, now the blob is known to have at most 1024 bytes we can simply read this in in one go (This saves reading it twice) + // OK, now the blob is known to have at most 1024 bytes we can simply read this in one go (This saves reading it twice) dataRc, err := blob.DataAsync() if err != nil { ctx.ServerError("DataAsync", err) @@ -279,9 +280,8 @@ func GetArchive(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - repoPath := repo_model.RepoPath(ctx.Params(":username"), ctx.Params(":reponame")) if ctx.Repo.GitRepo == nil { - gitRepo, err := git.OpenRepository(ctx, repoPath) + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return @@ -408,7 +408,7 @@ func canReadFiles(r *context.Repository) bool { return r.Permission.CanRead(unit.TypeCode) } -func base64Reader(s string) (io.Reader, error) { +func base64Reader(s string) (io.ReadSeeker, error) { b, err := base64.StdEncoding.DecodeString(s) if err != nil { return nil, err @@ -450,6 +450,8 @@ func ChangeFiles(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions) @@ -550,6 +552,8 @@ func CreateFile(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.CreateFileOptions) @@ -646,9 +650,12 @@ func UpdateFile(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.UpdateFileOptions) if ctx.Repo.Repository.IsEmpty { ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) + return } if apiOpts.BranchName == "" { @@ -756,13 +763,13 @@ func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.Ch } message := "" if len(createFiles) != 0 { - message += ctx.Tr("repo.editor.add", strings.Join(createFiles, ", ")+"\n") + message += ctx.Locale.TrString("repo.editor.add", strings.Join(createFiles, ", ")+"\n") } if len(updateFiles) != 0 { - message += ctx.Tr("repo.editor.update", strings.Join(updateFiles, ", ")+"\n") + message += ctx.Locale.TrString("repo.editor.update", strings.Join(updateFiles, ", ")+"\n") } if len(deleteFiles) != 0 { - message += ctx.Tr("repo.editor.delete", strings.Join(deleteFiles, ", ")) + message += ctx.Locale.TrString("repo.editor.delete", strings.Join(deleteFiles, ", ")) } return strings.Trim(message, "\n") } @@ -806,6 +813,8 @@ func DeleteFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.DeleteFileOptions) if !canWriteFiles(ctx, apiOpts.BranchName) { diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index ef152eef859..a1e3c9804ba 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -14,11 +14,11 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" repo_service "code.gitea.io/gitea/services/repository" ) @@ -123,7 +123,7 @@ func CreateFork(ctx *context.APIContext) { } return } - isMember, err := org.IsOrgMember(ctx.Doer.ID) + isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) return @@ -149,6 +149,8 @@ func CreateFork(ctx *context.APIContext) { if err != nil { if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { ctx.Error(http.StatusConflict, "ForkRepository", err) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "ForkRepository", err) } else { ctx.Error(http.StatusInternalServerError, "ForkRepository", err) } diff --git a/routers/api/v1/repo/git_hook.go b/routers/api/v1/repo/git_hook.go index 7e471e263b6..26ae84d08d3 100644 --- a/routers/api/v1/repo/git_hook.go +++ b/routers/api/v1/repo/git_hook.go @@ -6,10 +6,10 @@ package repo import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/api/v1/repo/git_ref.go b/routers/api/v1/repo/git_ref.go index 34d2dcfcc81..0fa58425b85 100644 --- a/routers/api/v1/repo/git_ref.go +++ b/routers/api/v1/repo/git_ref.go @@ -7,10 +7,10 @@ import ( "net/http" "net/url" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" ) // GetGitAllRefs get ref or an list all the refs of a repository diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go index e0f99b48fd0..ffd2313591b 100644 --- a/routers/api/v1/repo/hook.go +++ b/routers/api/v1/repo/hook.go @@ -7,16 +7,17 @@ package repo import ( "net/http" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" webhook_service "code.gitea.io/gitea/services/webhook" ) @@ -58,13 +59,7 @@ func ListHooks(ctx *context.APIContext) { RepoID: ctx.Repo.Repository.ID, } - count, err := webhook.CountWebhooksByOpts(opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - hooks, err := webhook.ListWebhooksByOpts(ctx, opts) + hooks, count, err := db.FindAndCount[webhook.Webhook](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -301,7 +296,7 @@ func DeleteHook(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" - if err := webhook.DeleteWebhookByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { if webhook.IsErrWebhookNotExist(err) { ctx.NotFound() } else { diff --git a/routers/api/v1/repo/hook_test.go b/routers/api/v1/repo/hook_test.go index 94a71e20ad7..37cf61c1edf 100644 --- a/routers/api/v1/repo/hook_test.go +++ b/routers/api/v1/repo/hook_test.go @@ -9,7 +9,7 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/contexttest" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index c6248bacecd..5e173abf883 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -5,6 +5,7 @@ package repo import ( + "errors" "fmt" "net/http" "strconv" @@ -18,14 +19,14 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" notify_service "code.gitea.io/gitea/services/notify" @@ -122,14 +123,14 @@ func SearchIssues(ctx *context.APIContext) { return } - var isClosed util.OptionalBool + var isClosed optional.Option[bool] switch ctx.FormString("state") { case "closed": - isClosed = util.OptionalBoolTrue + isClosed = optional.Some(true) case "all": - isClosed = util.OptionalBoolNone + isClosed = optional.None[bool]() default: - isClosed = util.OptionalBoolFalse + isClosed = optional.Some(false) } var ( @@ -142,7 +143,7 @@ func SearchIssues(ctx *context.APIContext) { Private: false, AllPublic: true, TopicOnly: false, - Collaborate: util.OptionalBoolNone, + Collaborate: optional.None[bool](), // This needs to be a column that is not nil in fixtures or // MySQL will return different results when sorting by null in some cases OrderBy: db.SearchOrderByAlphabetically, @@ -165,7 +166,7 @@ func SearchIssues(ctx *context.APIContext) { opts.OwnerID = owner.ID opts.AllLimited = false opts.AllPublic = false - opts.Collaborate = util.OptionalBoolFalse + opts.Collaborate = optional.Some(false) } if ctx.FormString("team") != "" { if ctx.FormString("owner") == "" { @@ -188,7 +189,7 @@ func SearchIssues(ctx *context.APIContext) { allPublic = true opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer } - repoIDs, _, err = repo_model.SearchRepositoryIDs(opts) + repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err) return @@ -204,14 +205,14 @@ func SearchIssues(ctx *context.APIContext) { keyword = "" } - var isPull util.OptionalBool + var isPull optional.Option[bool] switch ctx.FormString("type") { case "pulls": - isPull = util.OptionalBoolTrue + isPull = optional.Some(true) case "issues": - isPull = util.OptionalBoolFalse + isPull = optional.Some(false) default: - isPull = util.OptionalBoolNone + isPull = optional.None[bool]() } var includedAnyLabels []int64 @@ -268,28 +269,28 @@ func SearchIssues(ctx *context.APIContext) { } if since != 0 { - searchOpt.UpdatedAfterUnix = &since + searchOpt.UpdatedAfterUnix = optional.Some(since) } if before != 0 { - searchOpt.UpdatedBeforeUnix = &before + searchOpt.UpdatedBeforeUnix = optional.Some(before) } if ctx.IsSigned { ctxUserID := ctx.Doer.ID if ctx.FormBool("created") { - searchOpt.PosterID = &ctxUserID + searchOpt.PosterID = optional.Some(ctxUserID) } if ctx.FormBool("assigned") { - searchOpt.AssigneeID = &ctxUserID + searchOpt.AssigneeID = optional.Some(ctxUserID) } if ctx.FormBool("mentioned") { - searchOpt.MentionID = &ctxUserID + searchOpt.MentionID = optional.Some(ctxUserID) } if ctx.FormBool("review_requested") { - searchOpt.ReviewRequestedID = &ctxUserID + searchOpt.ReviewRequestedID = optional.Some(ctxUserID) } if ctx.FormBool("reviewed") { - searchOpt.ReviewedID = &ctxUserID + searchOpt.ReviewedID = optional.Some(ctxUserID) } } @@ -310,7 +311,7 @@ func SearchIssues(ctx *context.APIContext) { ctx.SetLinkHeader(int(total), limit) ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) } // ListIssues list the issues of a repository @@ -367,7 +368,7 @@ func ListIssues(ctx *context.APIContext) { // required: false // - name: created_by // in: query - // description: Only show items which were created by the the given user + // description: Only show items which were created by the given user // type: string // - name: assigned_by // in: query @@ -396,14 +397,14 @@ func ListIssues(ctx *context.APIContext) { return } - var isClosed util.OptionalBool + var isClosed optional.Option[bool] switch ctx.FormString("state") { case "closed": - isClosed = util.OptionalBoolTrue + isClosed = optional.Some(true) case "all": - isClosed = util.OptionalBoolNone + isClosed = optional.None[bool]() default: - isClosed = util.OptionalBoolFalse + isClosed = optional.Some(false) } keyword := ctx.FormTrim("q") @@ -413,7 +414,7 @@ func ListIssues(ctx *context.APIContext) { var labelIDs []int64 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { - labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted) + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) return @@ -425,7 +426,7 @@ func ListIssues(ctx *context.APIContext) { for i := range part { // uses names and fall back to ids // non existent milestones are discarded - mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx.Repo.Repository.ID, part[i]) + mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) if err == nil { mileIDs = append(mileIDs, mile.ID) continue @@ -452,14 +453,30 @@ func ListIssues(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - var isPull util.OptionalBool + isPull := optional.None[bool]() switch ctx.FormString("type") { case "pulls": - isPull = util.OptionalBoolTrue + isPull = optional.Some(true) case "issues": - isPull = util.OptionalBoolFalse - default: - isPull = util.OptionalBoolNone + isPull = optional.Some(false) + } + + if isPull.Has() && !ctx.Repo.CanReadIssuesOrPulls(isPull.Value()) { + ctx.NotFound() + return + } + + if !isPull.Has() { + canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) + canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) + if !canReadIssues && !canReadPulls { + ctx.NotFound() + return + } else if !canReadIssues { + isPull = optional.Some(true) + } else if !canReadPulls { + isPull = optional.Some(false) + } } // FIXME: we should be more efficient here @@ -485,10 +502,10 @@ func ListIssues(ctx *context.APIContext) { SortBy: issue_indexer.SortByCreatedDesc, } if since != 0 { - searchOpt.UpdatedAfterUnix = &since + searchOpt.UpdatedAfterUnix = optional.Some(since) } if before != 0 { - searchOpt.UpdatedBeforeUnix = &before + searchOpt.UpdatedBeforeUnix = optional.Some(before) } if len(labelIDs) == 1 && labelIDs[0] == 0 { searchOpt.NoLabelOnly = true @@ -509,13 +526,13 @@ func ListIssues(ctx *context.APIContext) { } if createdByID > 0 { - searchOpt.PosterID = &createdByID + searchOpt.PosterID = optional.Some(createdByID) } if assignedByID > 0 { - searchOpt.AssigneeID = &assignedByID + searchOpt.AssigneeID = optional.Some(assignedByID) } if mentionedByID > 0 { - searchOpt.MentionID = &mentionedByID + searchOpt.MentionID = optional.Some(mentionedByID) } ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) @@ -531,7 +548,7 @@ func ListIssues(ctx *context.APIContext) { ctx.SetLinkHeader(int(total), listOptions.PageSize) ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) } func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 { @@ -593,7 +610,11 @@ func GetIssue(ctx *context.APIContext) { } return } - ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, issue)) + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue)) } // CreateIssue create an issue of a repository @@ -631,6 +652,9 @@ func CreateIssue(ctx *context.APIContext) { // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueOption) var deadlineUnix timeutil.TimeStamp if form.Deadline != nil && ctx.Repo.CanWrite(unit.TypeIssues) { @@ -685,12 +709,14 @@ func CreateIssue(ctx *context.APIContext) { form.Labels = make([]int64, 0) } - if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "NewIssue", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewIssue", err) } - ctx.Error(http.StatusInternalServerError, "NewIssue", err) return } @@ -711,7 +737,7 @@ func CreateIssue(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, issue)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) } // EditIssue modify an issue of a repository @@ -803,7 +829,7 @@ func EditIssue(ctx *context.APIContext) { deadlineUnix = timeutil.TimeStamp(deadline.Unix()) } - if err := issues_model.UpdateIssueDeadline(issue, deadlineUnix, ctx.Doer); err != nil { + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) return } @@ -826,7 +852,11 @@ func EditIssue(ctx *context.APIContext) { err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer) if err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateAssignees", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + } return } } @@ -835,24 +865,25 @@ func EditIssue(ctx *context.APIContext) { issue.MilestoneID != *form.Milestone { oldMilestoneID := issue.MilestoneID issue.MilestoneID = *form.Milestone - if err = issue_service.ChangeMilestoneAssign(issue, ctx.Doer, oldMilestoneID); err != nil { + if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) return } } if form.State != nil { if issue.IsPull { - if pr, err := issue.GetPullRequest(); err != nil { + if err := issue.LoadPullRequest(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "GetPullRequest", err) return - } else if pr.HasMerged { + } + if issue.PullRequest.HasMerged { ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") return } } issue.IsClosed = api.StateClosed == api.StateType(*form.State) } - statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(issue, ctx.Doer) + statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer) if err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") @@ -880,7 +911,7 @@ func EditIssue(ctx *context.APIContext) { ctx.InternalServerError(err) return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, issue)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) } func DeleteIssue(ctx *context.APIContext) { @@ -990,7 +1021,7 @@ func UpdateIssueDeadline(ctx *context.APIContext) { deadlineUnix = timeutil.TimeStamp(deadline.Unix()) } - if err := issues_model.UpdateIssueDeadline(issue, deadlineUnix, ctx.Doer); err != nil { + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) return } diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index 60e554990f8..7a5c6d554da 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -8,12 +8,12 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -107,7 +107,7 @@ func ListIssueAttachments(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, issue).Attachments) + ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue).Attachments) } // CreateIssueAttachment creates an attachment and saves the given file @@ -153,6 +153,8 @@ func CreateIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" issue := getIssueFromContext(ctx) if issue == nil { @@ -176,7 +178,7 @@ func CreateIssueAttachment(ctx *context.APIContext) { filename = query } - attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, RepoID: ctx.Repo.Repository.ID, @@ -238,6 +240,8 @@ func EditIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" attachment := getIssueAttachmentSafeWrite(ctx) if attachment == nil { @@ -292,6 +296,8 @@ func DeleteIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" attachment := getIssueAttachmentSafeWrite(ctx) if attachment == nil { diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index c9179e69db6..070571ba62e 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -12,11 +12,13 @@ import ( issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -71,6 +73,11 @@ func ListIssueComments(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err) return } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + issue.Repo = ctx.Repo.Repository opts := &issues_model.FindCommentsOptions{ @@ -86,7 +93,7 @@ func ListIssueComments(ctx *context.APIContext) { return } - totalCount, err := issues_model.CountComments(opts) + totalCount, err := issues_model.CountComments(ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -271,12 +278,27 @@ func ListRepoIssueComments(ctx *context.APIContext) { return } + var isPull optional.Option[bool] + canReadIssue := ctx.Repo.CanRead(unit.TypeIssues) + canReadPull := ctx.Repo.CanRead(unit.TypePullRequests) + if canReadIssue && canReadPull { + isPull = optional.None[bool]() + } else if canReadIssue { + isPull = optional.Some(false) + } else if canReadPull { + isPull = optional.Some(true) + } else { + ctx.NotFound() + return + } + opts := &issues_model.FindCommentsOptions{ ListOptions: utils.GetListOptions(ctx), RepoID: ctx.Repo.Repository.ID, Type: issues_model.CommentTypeComment, Since: since, Before: before, + IsPull: isPull, } comments, err := issues_model.FindComments(ctx, opts) @@ -285,7 +307,7 @@ func ListRepoIssueComments(ctx *context.APIContext) { return } - totalCount, err := issues_model.CountComments(opts) + totalCount, err := issues_model.CountComments(ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -301,10 +323,6 @@ func ListRepoIssueComments(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "LoadIssues", err) return } - if err := comments.LoadPosters(ctx); err != nil { - ctx.Error(http.StatusInternalServerError, "LoadPosters", err) - return - } if err := comments.LoadAttachments(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) return @@ -358,6 +376,9 @@ func CreateIssueComment(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueCommentOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -365,14 +386,23 @@ func CreateIssueComment(ctx *context.APIContext) { return } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { - ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Tr("repo.issues.comment_on_locked"))) + ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Locale.TrString("repo.issues.comment_on_locked"))) return } comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil) if err != nil { - ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "CreateIssueComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + } return } @@ -434,6 +464,11 @@ func GetIssueComment(ctx *context.APIContext) { return } + if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound() + return + } + if comment.Type != issues_model.CommentTypeComment { ctx.Status(http.StatusNoContent) return @@ -486,6 +521,8 @@ func EditIssueComment(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.EditIssueCommentOption) editIssueComment(ctx, *form) @@ -552,7 +589,17 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { + if err := comment.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.Status(http.StatusForbidden) return } @@ -565,7 +612,11 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) oldContent := comment.Content comment.Content = form.Body if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + } return } @@ -655,7 +706,17 @@ func deleteIssueComment(ctx *context.APIContext) { return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.IsAdmin()) { + if err := comment.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.Status(http.StatusForbidden) return } else if comment.Type != issues_model.CommentTypeComment { diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index c30e8278dbc..4096cbf07b0 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -4,16 +4,18 @@ package repo import ( + "errors" "net/http" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -154,8 +156,12 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "400": // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" // Check if comment exists and load comment comment := getIssueCommentSafe(ctx) @@ -180,7 +186,7 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { filename = query } - attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, RepoID: ctx.Repo.Repository.ID, @@ -197,7 +203,11 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { } if err = issue_service.UpdateComment(ctx, comment, ctx.Doer, comment.Content); err != nil { - ctx.ServerError("UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateComment", err) + } else { + ctx.ServerError("UpdateComment", err) + } return } @@ -245,7 +255,8 @@ func EditIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "404": // "$ref": "#/responses/error" - + // "423": + // "$ref": "#/responses/repoArchivedError" attach := getIssueCommentAttachmentSafeWrite(ctx) if attach == nil { return @@ -297,7 +308,8 @@ func DeleteIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/error" - + // "423": + // "$ref": "#/responses/repoArchivedError" attach := getIssueCommentAttachmentSafeWrite(ctx) if attach == nil { return @@ -325,6 +337,10 @@ func getIssueCommentSafe(ctx *context.APIContext) *issues_model.Comment { return nil } + if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { + return nil + } + comment.Issue.Repo = ctx.Repo.Repository return comment diff --git a/routers/api/v1/repo/issue_dependency.go b/routers/api/v1/repo/issue_dependency.go index 5ea3836da1a..c40e92c01bb 100644 --- a/routers/api/v1/repo/issue_dependency.go +++ b/routers/api/v1/repo/issue_dependency.go @@ -11,10 +11,10 @@ import ( issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -102,23 +102,24 @@ func GetIssueDependencies(ctx *context.APIContext) { return } - var lastRepoID int64 - var lastPerm access_model.Permission + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission for _, blocker := range blockersInfo { // Get the permissions for this repository - perm := lastPerm - if lastRepoID != blocker.Repository.ID { - if blocker.Repository.ID == ctx.Repo.Repository.ID { - perm = ctx.Repo.Permission - } else { - var err error - perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return - } + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[blocker.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return } - lastRepoID = blocker.Repository.ID + repoPerms[blocker.RepoID] = perm } // check permission @@ -152,7 +153,7 @@ func GetIssueDependencies(ctx *context.APIContext) { blockerIssues = append(blockerIssues, &blocker.Issue) } - ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, blockerIssues)) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, blockerIssues)) } // CreateIssueDependency create a new issue dependencies @@ -187,6 +188,8 @@ func CreateIssueDependency(ctx *context.APIContext) { // "$ref": "#/responses/Issue" // "404": // description: the issue does not exist + // "423": + // "$ref": "#/responses/repoArchivedError" // We want to make <:index> depend on
, i.e. <:index> is the target target := getParamsIssue(ctx) @@ -211,7 +214,7 @@ func CreateIssueDependency(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, target)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target)) } // RemoveIssueDependency remove an issue dependency @@ -246,6 +249,8 @@ func RemoveIssueDependency(ctx *context.APIContext) { // "$ref": "#/responses/Issue" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" // We want to make <:index> depend on , i.e. <:index> is the target target := getParamsIssue(ctx) @@ -270,7 +275,7 @@ func RemoveIssueDependency(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, target)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target)) } // GetIssueBlocks list issues that are blocked by this issue @@ -341,29 +346,31 @@ func GetIssueBlocks(ctx *context.APIContext) { return } - var lastRepoID int64 - var lastPerm access_model.Permission - var issues []*issues_model.Issue + + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + for i, depMeta := range deps { if i < skip || i >= max { continue } // Get the permissions for this repository - perm := lastPerm - if lastRepoID != depMeta.Repository.ID { - if depMeta.Repository.ID == ctx.Repo.Repository.ID { - perm = ctx.Repo.Permission - } else { - var err error - perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return - } + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[depMeta.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return } - lastRepoID = depMeta.Repository.ID + repoPerms[depMeta.RepoID] = perm } if !perm.CanReadIssuesOrPulls(depMeta.Issue.IsPull) { @@ -374,7 +381,7 @@ func GetIssueBlocks(ctx *context.APIContext) { issues = append(issues, &depMeta.Issue) } - ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) } // CreateIssueBlocking block the issue given in the body by the issue in path @@ -431,7 +438,7 @@ func CreateIssueBlocking(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, dependency)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) } // RemoveIssueBlocking unblock the issue given in the body by the issue in path @@ -488,7 +495,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, dependency)) + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) } func getParamsIssue(ctx *context.APIContext) *issues_model.Issue { @@ -572,7 +579,7 @@ func createIssueDependency(ctx *context.APIContext, target, dependency *issues_m return } - err := issues_model.CreateIssueDependency(ctx.Doer, target, dependency) + err := issues_model.CreateIssueDependency(ctx, ctx.Doer, target, dependency) if err != nil { ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) return @@ -598,7 +605,7 @@ func removeIssueDependency(ctx *context.APIContext, target, dependency *issues_m return } - err := issues_model.RemoveIssueDependency(ctx.Doer, target, dependency, issues_model.DependencyTypeBlockedBy) + err := issues_model.RemoveIssueDependency(ctx, ctx.Doer, target, dependency, issues_model.DependencyTypeBlockedBy) if err != nil { ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) return diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index b050a397f2b..7d9f85d2aa2 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -8,9 +8,9 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -107,7 +107,7 @@ func AddIssueLabels(ctx *context.APIContext) { return } - if err = issue_service.AddLabels(issue, ctx.Doer, labels); err != nil { + if err = issue_service.AddLabels(ctx, issue, ctx.Doer, labels); err != nil { ctx.Error(http.StatusInternalServerError, "AddLabels", err) return } @@ -186,7 +186,7 @@ func DeleteIssueLabel(ctx *context.APIContext) { return } - if err := issue_service.RemoveLabel(issue, ctx.Doer, label); err != nil { + if err := issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteIssueLabel", err) return } @@ -237,7 +237,7 @@ func ReplaceIssueLabels(ctx *context.APIContext) { return } - if err := issue_service.ReplaceLabels(issue, ctx.Doer, labels); err != nil { + if err := issue_service.ReplaceLabels(ctx, issue, ctx.Doer, labels); err != nil { ctx.Error(http.StatusInternalServerError, "ReplaceLabels", err) return } @@ -298,7 +298,7 @@ func ClearIssueLabels(ctx *context.APIContext) { return } - if err := issue_service.ClearLabels(issue, ctx.Doer); err != nil { + if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ClearLabels", err) return } @@ -317,7 +317,7 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return nil, nil, err } - labels, err := issues_model.GetLabelsByIDs(form.Labels, "id", "repo_id", "org_id") + labels, err := issues_model.GetLabelsByIDs(ctx, form.Labels, "id", "repo_id", "org_id", "name", "exclusive") if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) return nil, nil, err diff --git a/routers/api/v1/repo/issue_pin.go b/routers/api/v1/repo/issue_pin.go index d9b1bcc8940..af3e06332ad 100644 --- a/routers/api/v1/repo/issue_pin.go +++ b/routers/api/v1/repo/issue_pin.go @@ -7,8 +7,8 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -207,7 +207,7 @@ func ListPinnedIssues(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) } // ListPinnedPullRequests returns a list of all pinned PRs @@ -240,18 +240,12 @@ func ListPinnedPullRequests(ctx *context.APIContext) { } apiPrs := make([]*api.PullRequest, len(issues)) + if err := issues.LoadPullRequests(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPullRequests", err) + return + } for i, currentIssue := range issues { - pr, err := currentIssue.GetPullRequest() - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetPullRequest", err) - return - } - - if err = pr.LoadIssue(ctx); err != nil { - ctx.Error(http.StatusInternalServerError, "LoadIssue", err) - return - } - + pr := currentIssue.PullRequest if err = pr.LoadAttributes(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) return diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index 5210d4ccedb..3ff3d19f13e 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -8,11 +8,13 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" ) // GetIssueCommentReactions list reactions of a comment from an issue @@ -61,6 +63,12 @@ func GetIssueCommentReactions(ctx *context.APIContext) { if err := comment.LoadIssue(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "comment.LoadIssue", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return } if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { @@ -68,7 +76,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) { return } - reactions, _, err := issues_model.FindCommentReactions(comment.IssueID, comment.ID) + reactions, _, err := issues_model.FindCommentReactions(ctx, comment.IssueID, comment.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "FindCommentReactions", err) return @@ -190,9 +198,19 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp return } - err = comment.LoadIssue(ctx) - if err != nil { + if err = comment.LoadIssue(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "comment.LoadIssue() failed", err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + + if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound() + return } if comment.Issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull) { @@ -202,9 +220,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp if isCreateType { // PostIssueCommentReaction part - reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -225,7 +243,7 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp }) } else { // DeleteIssueCommentReaction part - err = issues_model.DeleteCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + err = issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteCommentReaction", err) return @@ -292,7 +310,7 @@ func GetIssueReactions(ctx *context.APIContext) { return } - reactions, count, err := issues_model.FindIssueReactions(issue.ID, utils.GetListOptions(ctx)) + reactions, count, err := issues_model.FindIssueReactions(ctx, issue.ID, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "FindIssueReactions", err) return @@ -418,9 +436,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i if isCreateType { // PostIssueReaction part - reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Reaction) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -429,7 +447,7 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i Created: reaction.CreatedUnix.AsTime(), }) } else { - ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) + ctx.Error(http.StatusInternalServerError, "CreateIssueReaction", err) } return } @@ -441,7 +459,7 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i }) } else { // DeleteIssueReaction part - err = issues_model.DeleteIssueReaction(ctx.Doer.ID, issue.ID, form.Reaction) + err = issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Reaction) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteIssueReaction", err) return diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go index 75fa8631385..d9054e8f775 100644 --- a/routers/api/v1/repo/issue_stopwatch.go +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -8,8 +8,8 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -152,7 +152,7 @@ func DeleteIssueStopwatch(ctx *context.APIContext) { return } - if err := issues_model.CancelStopwatch(ctx.Doer, issue); err != nil { + if err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { ctx.Error(http.StatusInternalServerError, "CancelStopwatch", err) return } @@ -177,12 +177,12 @@ func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_m return nil, errors.New("Unable to write to PRs") } - if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { ctx.Status(http.StatusForbidden) return nil, errors.New("Cannot use time tracker") } - if issues_model.StopwatchExists(ctx.Doer.ID, issue.ID) != shouldExist { + if issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) != shouldExist { if shouldExist { ctx.Error(http.StatusConflict, "StopwatchExists", "cannot stop/cancel a non existent stopwatch") err = errors.New("cannot stop/cancel a non existent stopwatch") @@ -218,19 +218,19 @@ func GetStopwatches(ctx *context.APIContext) { // "200": // "$ref": "#/responses/StopWatchList" - sws, err := issues_model.GetUserStopwatches(ctx.Doer.ID, utils.GetListOptions(ctx)) + sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetUserStopwatches", err) return } - count, err := issues_model.CountUserStopwatches(ctx.Doer.ID) + count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) if err != nil { ctx.InternalServerError(err) return } - apiSWs, err := convert.ToStopWatches(sws) + apiSWs, err := convert.ToStopWatches(ctx, sws) if err != nil { ctx.Error(http.StatusInternalServerError, "APIFormat", err) return diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go index 1fec0294658..a535172462e 100644 --- a/routers/api/v1/repo/issue_subscription.go +++ b/routers/api/v1/repo/issue_subscription.go @@ -9,9 +9,9 @@ import ( issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -132,7 +132,7 @@ func setIssueSubscription(ctx *context.APIContext, watch bool) { return } - current, err := issues_model.CheckIssueWatch(user, issue) + current, err := issues_model.CheckIssueWatch(ctx, user, issue) if err != nil { ctx.Error(http.StatusInternalServerError, "CheckIssueWatch", err) return @@ -145,7 +145,7 @@ func setIssueSubscription(ctx *context.APIContext, watch bool) { } // Update watch state - if err := issues_model.CreateOrUpdateIssueWatch(user.ID, issue.ID, watch); err != nil { + if err := issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, issue.ID, watch); err != nil { ctx.Error(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err) return } @@ -196,7 +196,7 @@ func CheckIssueSubscription(ctx *context.APIContext) { return } - watching, err := issues_model.CheckIssueWatch(ctx.Doer, issue) + watching, err := issues_model.CheckIssueWatch(ctx, ctx.Doer, issue) if err != nil { ctx.InternalServerError(err) return @@ -206,7 +206,7 @@ func CheckIssueSubscription(ctx *context.APIContext) { Ignored: !watching, Reason: nil, CreatedAt: issue.CreatedUnix.AsTime(), - URL: issue.APIURL() + "/subscriptions", + URL: issue.APIURL(ctx) + "/subscriptions", RepositoryURL: ctx.Repo.Repository.APIURL(), }) } diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index ecbc5cf00f8..f83855efac1 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -12,10 +12,10 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -138,7 +138,7 @@ func ListTrackedTimes(ctx *context.APIContext) { } ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, trackedTimes)) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) } // AddTime add time manual to the given issue @@ -191,7 +191,7 @@ func AddTime(ctx *context.APIContext) { return } - if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { ctx.Error(http.StatusBadRequest, "", "time tracking disabled") return @@ -225,7 +225,7 @@ func AddTime(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) return } - ctx.JSON(http.StatusOK, convert.ToTrackedTime(ctx, trackedTime)) + ctx.JSON(http.StatusOK, convert.ToTrackedTime(ctx, user, trackedTime)) } // ResetIssueTime reset time manual to the given issue @@ -274,7 +274,7 @@ func ResetIssueTime(ctx *context.APIContext) { return } - if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) return @@ -283,7 +283,7 @@ func ResetIssueTime(ctx *context.APIContext) { return } - err = issues_model.DeleteIssueUserTimes(issue, ctx.Doer) + err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer) if err != nil { if db.IsErrNotExist(err) { ctx.Error(http.StatusNotFound, "DeleteIssueUserTimes", err) @@ -347,7 +347,7 @@ func DeleteTime(ctx *context.APIContext) { return } - if !ctx.Repo.CanUseTimetracker(issue, ctx.Doer) { + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) return @@ -356,7 +356,7 @@ func DeleteTime(ctx *context.APIContext) { return } - time, err := issues_model.GetTrackedTimeByID(ctx.ParamsInt64(":id")) + time, err := issues_model.GetTrackedTimeByID(ctx, ctx.ParamsInt64(":id")) if err != nil { if db.IsErrNotExist(err) { ctx.NotFound(err) @@ -376,7 +376,7 @@ func DeleteTime(ctx *context.APIContext) { return } - err = issues_model.DeleteTime(time) + err = issues_model.DeleteTime(ctx, time) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteTime", err) return @@ -455,7 +455,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) return } - ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, trackedTimes)) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) } // ListTrackedTimesByRepository lists all tracked times of the repository @@ -567,7 +567,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { } ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, trackedTimes)) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) } // ListMyTrackedTimes lists all tracked times of the current user @@ -629,5 +629,5 @@ func ListMyTrackedTimes(ctx *context.APIContext) { } ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, trackedTimes)) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) } diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go index b7d820d1d83..88444a26250 100644 --- a/routers/api/v1/repo/key.go +++ b/routers/api/v1/repo/key.go @@ -15,12 +15,12 @@ import ( "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -83,20 +83,14 @@ func ListDeployKeys(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - opts := &asymkey_model.ListDeployKeysOptions{ + opts := asymkey_model.ListDeployKeysOptions{ ListOptions: utils.GetListOptions(ctx), RepoID: ctx.Repo.Repository.ID, KeyID: ctx.FormInt64("key_id"), Fingerprint: ctx.FormString("fingerprint"), } - keys, err := asymkey_model.ListDeployKeys(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - count, err := asymkey_model.CountDeployKeys(opts) + keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -105,7 +99,7 @@ func ListDeployKeys(ctx *context.APIContext) { apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) apiKeys := make([]*api.DeployKey, len(keys)) for i := range keys { - if err := keys[i].GetContent(); err != nil { + if err := keys[i].GetContent(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "GetContent", err) return } @@ -159,7 +153,13 @@ func GetDeployKey(ctx *context.APIContext) { return } - if err = key.GetContent(); err != nil { + // this check make it more consistent + if key.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + + if err = key.GetContent(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "GetContent", err) return } @@ -238,7 +238,7 @@ func CreateDeployKey(ctx *context.APIContext) { return } - key, err := asymkey_model.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly) + key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly) if err != nil { HandleAddKeyError(ctx, err) return @@ -279,7 +279,7 @@ func DeleteDeploykey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := asymkey_service.DeleteDeployKey(ctx.Doer, ctx.ParamsInt64(":id")); err != nil { + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.ParamsInt64(":id")); err != nil { if asymkey_model.IsErrKeyAccessDenied(err) { ctx.Error(http.StatusForbidden, "", "You do not have access to this key") } else { diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go index e93c72a9f5f..b6eb51fd20a 100644 --- a/routers/api/v1/repo/label.go +++ b/routers/api/v1/repo/label.go @@ -9,11 +9,11 @@ import ( "strconv" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/label" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -55,7 +55,7 @@ func ListLabels(ctx *context.APIContext) { return } - count, err := issues_model.CountLabelsByRepoID(ctx.Repo.Repository.ID) + count, err := issues_model.CountLabelsByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.InternalServerError(err) return @@ -240,7 +240,7 @@ func EditLabel(ctx *context.APIContext) { l.Description = *form.Description } l.SetArchived(form.IsArchived != nil && *form.IsArchived) - if err := issues_model.UpdateLabel(l); err != nil { + if err := issues_model.UpdateLabel(ctx, l); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) return } @@ -276,7 +276,7 @@ func DeleteLabel(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := issues_model.DeleteLabel(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { + if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteLabel", err) return } diff --git a/routers/api/v1/repo/language.go b/routers/api/v1/repo/language.go index 12f1761ad08..f1d5bbe45fe 100644 --- a/routers/api/v1/repo/language.go +++ b/routers/api/v1/repo/language.go @@ -9,8 +9,8 @@ import ( "strconv" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) type languageResponse []*repo_model.LanguageStat diff --git a/routers/api/v1/repo/main_test.go b/routers/api/v1/repo/main_test.go index bc048505f47..451f34d72ff 100644 --- a/routers/api/v1/repo/main_test.go +++ b/routers/api/v1/repo/main_test.go @@ -4,7 +4,6 @@ package repo import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -14,7 +13,6 @@ import ( func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", "..", ".."), SetUp: func() error { setting.LoadQueueSettings() return webhook_service.Init() diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 4ddd4523729..2caaa130e8d 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -17,7 +17,6 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" @@ -26,6 +25,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" @@ -93,7 +93,7 @@ func Migrate(ctx *context.APIContext) { if repoOwner.IsOrganization() { // Check ownership of organization. - isOwner, err := organization.OrgFromUser(repoOwner).IsOwnedBy(ctx.Doer.ID) + isOwner, err := organization.OrgFromUser(repoOwner).IsOwnedBy(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOwnedBy", err) return @@ -200,7 +200,7 @@ func Migrate(ctx *context.APIContext) { } if repo != nil { - if errDelete := repo_service.DeleteRepositoryDirectly(ctx, ctx.Doer, repoOwner.ID, repo.ID); errDelete != nil { + if errDelete := repo_service.DeleteRepositoryDirectly(ctx, ctx.Doer, repo.ID); errDelete != nil { log.Error("DeleteRepository: %v", errDelete) } } diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go index fff9493a237..b9534016e4b 100644 --- a/routers/api/v1/repo/milestone.go +++ b/routers/api/v1/repo/milestone.go @@ -9,12 +9,14 @@ import ( "strconv" "time" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -58,14 +60,21 @@ func ListMilestones(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - milestones, total, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ + state := api.StateType(ctx.FormString("state")) + var isClosed optional.Option[bool] + switch state { + case api.StateClosed, api.StateOpen: + isClosed = optional.Some(state == api.StateClosed) + } + + milestones, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ ListOptions: utils.GetListOptions(ctx), RepoID: ctx.Repo.Repository.ID, - State: api.StateType(ctx.FormString("state")), + IsClosed: isClosed, Name: ctx.FormString("name"), }) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetMilestones", err) + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[issues_model.Milestone]", err) return } @@ -163,7 +172,7 @@ func CreateMilestone(ctx *context.APIContext) { milestone.ClosedDateUnix = timeutil.TimeStampNow() } - if err := issues_model.NewMilestone(milestone); err != nil { + if err := issues_model.NewMilestone(ctx, milestone); err != nil { ctx.Error(http.StatusInternalServerError, "NewMilestone", err) return } @@ -225,7 +234,7 @@ func EditMilestone(ctx *context.APIContext) { milestone.IsClosed = *form.State == string(api.StateClosed) } - if err := issues_model.UpdateMilestone(milestone, oldIsClosed); err != nil { + if err := issues_model.UpdateMilestone(ctx, milestone, oldIsClosed); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateMilestone", err) return } @@ -264,7 +273,7 @@ func DeleteMilestone(ctx *context.APIContext) { return } - if err := issues_model.DeleteMilestoneByRepoID(ctx.Repo.Repository.ID, m.ID); err != nil { + if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, m.ID); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteMilestoneByRepoID", err) return } @@ -286,7 +295,7 @@ func getMilestoneByIDOrName(ctx *context.APIContext) *issues_model.Milestone { } } - milestone, err := issues_model.GetMilestoneByRepoIDANDName(ctx.Repo.Repository.ID, mile) + milestone, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, mile) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { ctx.NotFound() diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go index 12542c808f6..864644e1efc 100644 --- a/routers/api/v1/repo/mirror.go +++ b/routers/api/v1/repo/mirror.go @@ -13,12 +13,12 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" @@ -176,7 +176,7 @@ func ListPushMirrors(ctx *context.APIContext) { responsePushMirrors := make([]*api.PushMirror, 0, len(pushMirrors)) for _, mirror := range pushMirrors { - m, err := convert.ToPushMirror(mirror) + m, err := convert.ToPushMirror(ctx, mirror) if err == nil { responsePushMirrors = append(responsePushMirrors, m) } @@ -227,12 +227,19 @@ func GetPushMirrorByName(ctx *context.APIContext) { mirrorName := ctx.Params(":name") // Get push mirror of a specific repo by remoteName - pushMirror, err := repo_model.GetPushMirror(ctx, repo_model.PushMirrorOptions{RepoID: ctx.Repo.Repository.ID, RemoteName: mirrorName}) + pushMirror, exist, err := db.Get[repo_model.PushMirror](ctx, repo_model.PushMirrorOptions{ + RepoID: ctx.Repo.Repository.ID, + RemoteName: mirrorName, + }.ToConds()) if err != nil { - ctx.Error(http.StatusNotFound, "GetPushMirrors", err) + ctx.Error(http.StatusInternalServerError, "GetPushMirrors", err) + return + } else if !exist { + ctx.Error(http.StatusNotFound, "GetPushMirrors", nil) return } - m, err := convert.ToPushMirror(pushMirror) + + m, err := convert.ToPushMirror(ctx, pushMirror) if err != nil { ctx.ServerError("GetPushMirrorByRemoteName", err) return @@ -353,15 +360,22 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro return } + remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress) + if err != nil { + ctx.ServerError("SanitizeURL", err) + return + } + pushMirror := &repo_model.PushMirror{ - RepoID: repo.ID, - Repo: repo, - RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), - Interval: interval, - SyncOnCommit: mirrorOption.SyncOnCommit, + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + Interval: interval, + SyncOnCommit: mirrorOption.SyncOnCommit, + RemoteAddress: remoteAddress, } - if err = repo_model.InsertPushMirror(ctx, pushMirror); err != nil { + if err = db.Insert(ctx, pushMirror); err != nil { ctx.ServerError("InsertPushMirror", err) return } @@ -374,7 +388,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro ctx.ServerError("AddPushMirrorRemote", err) return } - m, err := convert.ToPushMirror(pushMirror) + m, err := convert.ToPushMirror(ctx, pushMirror) if err != nil { ctx.ServerError("ToPushMirror", err) return diff --git a/routers/api/v1/repo/notes.go b/routers/api/v1/repo/notes.go index 1bd66101f0d..a4a1d4eab7c 100644 --- a/routers/api/v1/repo/notes.go +++ b/routers/api/v1/repo/notes.go @@ -7,9 +7,9 @@ import ( "fmt" "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -36,6 +36,14 @@ func GetNote(ctx *context.APIContext) { // description: a git ref or commit sha // type: string // required: true + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean // responses: // "200": // "$ref": "#/responses/Note" @@ -58,7 +66,7 @@ func getNote(ctx *context.APIContext, identifier string) { return } - commitSHA, err := ctx.Repo.GitRepo.ConvertToSHA1(identifier) + commitID, err := ctx.Repo.GitRepo.ConvertToGitID(identifier) if err != nil { if git.IsErrNotExist(err) { ctx.NotFound(err) @@ -69,7 +77,7 @@ func getNote(ctx *context.APIContext, identifier string) { } var note git.Note - if err := git.GetNote(ctx, ctx.Repo.GitRepo, commitSHA.String(), ¬e); err != nil { + if err := git.GetNote(ctx, ctx.Repo.GitRepo, commitID.String(), ¬e); err != nil { if git.IsErrNotExist(err) { ctx.NotFound(identifier) return @@ -78,7 +86,15 @@ func getNote(ctx *context.APIContext, identifier string) { return } - cmt, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, note.Commit, nil, convert.ToCommitOptions{Stat: true}) + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") + + cmt, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, note.Commit, nil, + convert.ToCommitOptions{ + Stat: true, + Verification: verification, + Files: files, + }) if err != nil { ctx.Error(http.StatusInternalServerError, "ToCommit", err) return diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go index 080908ab7ed..0e0601b7d93 100644 --- a/routers/api/v1/repo/patch.go +++ b/routers/api/v1/repo/patch.go @@ -10,10 +10,10 @@ import ( "code.gitea.io/gitea/models" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/repository/files" ) @@ -47,6 +47,8 @@ func ApplyDiffPatch(ctx *context.APIContext) { // "$ref": "#/responses/FileResponse" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions) opts := &files.ApplyDiffPatchOptions{ diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 4f22a059a13..e43366ff14b 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -21,8 +21,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -31,6 +32,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/gitdiff" @@ -95,13 +97,17 @@ func ListPullRequests(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + labelIDs, err := base.StringsToInt64s(ctx.FormStrings("labels")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "PullRequests", err) + return + } listOptions := utils.GetListOptions(ctx) - prs, maxResults, err := issues_model.PullRequests(ctx, ctx.Repo.Repository.ID, &issues_model.PullRequestsOptions{ ListOptions: listOptions, State: ctx.FormTrim("state"), SortType: ctx.FormTrim("sort"), - Labels: ctx.FormStrings("labels"), + Labels: labelIDs, MilestoneID: ctx.FormInt64("milestone"), }) if err != nil { @@ -186,6 +192,91 @@ func GetPullRequest(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) } +// GetPullRequest returns a single PR based on index +func GetPullRequestByBaseHead(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{base}/{head} repository repoGetPullRequestByBaseHead + // --- + // summary: Get a pull request by base and head + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: base + // in: path + // description: base of the pull request to get + // type: string + // required: true + // - name: head + // in: path + // description: head of the pull request to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + + var headRepoID int64 + var headBranch string + head := ctx.Params("*") + if strings.Contains(head, ":") { + split := strings.SplitN(head, ":", 2) + headBranch = split[1] + var owner, name string + if strings.Contains(split[0], "/") { + split = strings.Split(split[0], "/") + owner = split[0] + name = split[1] + } else { + owner = split[0] + name = ctx.Repo.Repository.Name + } + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, name) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerName", err) + } + return + } + headRepoID = repo.ID + } else { + headRepoID = ctx.Repo.Repository.ID + headBranch = head + } + + pr, err := issues_model.GetPullRequestByBaseHeadInfo(ctx, ctx.Repo.Repository.ID, headRepoID, ctx.Params(":base"), headBranch) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByBaseHeadInfo", err) + } + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} + // DownloadPullDiffOrPatch render a pull's raw diff or patch func DownloadPullDiffOrPatch(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}.{diffType} repository repoDownloadPullDiffOrPatch @@ -276,12 +367,16 @@ func CreatePullRequest(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/PullRequest" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" // "422": // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" form := *web.GetForm(ctx).(*api.CreatePullRequestOption) if form.Head == form.Base { @@ -422,9 +517,11 @@ func CreatePullRequest(ctx *context.APIContext) { if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) } - ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) return } @@ -522,7 +619,7 @@ func EditPullRequest(ctx *context.APIContext) { deadlineUnix = timeutil.TimeStamp(deadline.Unix()) } - if err := issues_model.UpdateIssueDeadline(issue, deadlineUnix, ctx.Doer); err != nil { + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) return } @@ -542,6 +639,8 @@ func EditPullRequest(ctx *context.APIContext) { if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateAssignees", err) } else { ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) } @@ -553,7 +652,7 @@ func EditPullRequest(ctx *context.APIContext) { issue.MilestoneID != form.Milestone { oldMilestoneID := issue.MilestoneID issue.MilestoneID = form.Milestone - if err = issue_service.ChangeMilestoneAssign(issue, ctx.Doer, oldMilestoneID); err != nil { + if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) return } @@ -576,7 +675,7 @@ func EditPullRequest(ctx *context.APIContext) { labels = append(labels, orgLabels...) } - if err = issues_model.ReplaceIssueLabels(issue, labels, ctx.Doer); err != nil { + if err = issues_model.ReplaceIssueLabels(ctx, issue, labels, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ReplaceLabelsError", err) return } @@ -589,7 +688,7 @@ func EditPullRequest(ctx *context.APIContext) { } issue.IsClosed = api.StateClosed == api.StateType(*form.State) } - statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(issue, ctx.Doer) + statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer) if err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") @@ -623,9 +722,8 @@ func EditPullRequest(ctx *context.APIContext) { } else if models.IsErrPullRequestHasMerged(err) { ctx.Error(http.StatusConflict, "IsErrPullRequestHasMerged", err) return - } else { - ctx.InternalServerError(err) } + ctx.InternalServerError(err) return } notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, form.Base) @@ -741,6 +839,8 @@ func MergePullRequest(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*forms.MergePullRequestForm) @@ -807,7 +907,7 @@ func MergePullRequest(ctx *context.APIContext) { // handle manually-merged mark if manuallyMerged { - if err := pull_service.MergedManually(pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { if models.IsErrInvalidMergeStyle(err) { ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) return @@ -903,13 +1003,17 @@ func MergePullRequest(ctx *context.APIContext) { if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil { headRepo = ctx.Repo.GitRepo } else { - headRepo, err = git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { - ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.RepoPath()), err) + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) return } defer headRepo.Close() } + if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { + ctx.Error(http.StatusInternalServerError, "RetargetChildrenOnMerge", err) + return + } if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch); err != nil { switch { case git.IsErrBranchNotExist(err): @@ -969,6 +1073,8 @@ func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) return nil, nil, nil, nil, "", "" } headBranch = headInfos[1] + // The head repository can also point to the same repo + isSameRepo = ctx.Repo.Owner.ID == headUser.ID } else { ctx.NotFound() @@ -997,7 +1103,7 @@ func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) headRepo = ctx.Repo.Repository headGitRepo = ctx.Repo.GitRepo } else { - headGitRepo, err = git.OpenRepository(ctx, repo_model.RepoPath(headUser.Name, headRepo.Name)) + headGitRepo, err = gitrepo.OpenRepository(ctx, headRepo) if err != nil { ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return nil, nil, nil, nil, "", "" @@ -1196,6 +1302,8 @@ func CancelScheduledAutoMerge(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" pullIndex := ctx.ParamsInt64(":index") pull, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, pullIndex) @@ -1269,6 +1377,14 @@ func GetPullRequestCommits(ctx *context.APIContext) { // in: query // description: page size of results // type: integer + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean // responses: // "200": // "$ref": "#/responses/CommitList" @@ -1291,7 +1407,7 @@ func GetPullRequestCommits(ctx *context.APIContext) { } var prInfo *git.CompareInfo - baseGitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) if err != nil { ctx.ServerError("OpenRepository", err) return @@ -1316,15 +1432,22 @@ func GetPullRequestCommits(ctx *context.APIContext) { userCache := make(map[string]*user_model.User) - start, end := listOptions.GetStartEnd() + start, limit := listOptions.GetSkipTake() - if end > totalNumberOfCommits { - end = totalNumberOfCommits - } + limit = min(limit, totalNumberOfCommits-start) + limit = max(limit, 0) + + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") - apiCommits := make([]*api.Commit, 0, end-start) - for i := start; i < end; i++ { - apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, baseGitRepo, commits[i], userCache, convert.ToCommitOptions{Stat: true}) + apiCommits := make([]*api.Commit, 0, limit) + for i := start; i < start+limit; i++ { + apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, baseGitRepo, commits[i], userCache, + convert.ToCommitOptions{ + Stat: true, + Verification: verification, + Files: files, + }) if err != nil { ctx.ServerError("toCommit", err) return @@ -1436,7 +1559,7 @@ func GetPullRequestFiles(ctx *context.APIContext) { maxLines := setting.Git.MaxGitDiffLines // FIXME: If there are too many files in the repo, may cause some unpredictable issues. - diff, err := gitdiff.GetDiff(baseGitRepo, + diff, err := gitdiff.GetDiff(ctx, baseGitRepo, &gitdiff.DiffOptions{ BeforeCommitID: startCommitID, AfterCommitID: endCommitID, @@ -1456,19 +1579,14 @@ func GetPullRequestFiles(ctx *context.APIContext) { totalNumberOfFiles := diff.NumFiles totalNumberOfPages := int(math.Ceil(float64(totalNumberOfFiles) / float64(listOptions.PageSize))) - start, end := listOptions.GetStartEnd() + start, limit := listOptions.GetSkipTake() - if end > totalNumberOfFiles { - end = totalNumberOfFiles - } + limit = min(limit, totalNumberOfFiles-start) - lenFiles := end - start - if lenFiles < 0 { - lenFiles = 0 - } + limit = max(limit, 0) - apiFiles := make([]*api.ChangedFile, 0, lenFiles) - for i := start; i < end; i++ { + apiFiles := make([]*api.ChangedFile, 0, limit) + for i := start; i < start+limit; i++ { apiFiles = append(apiFiles, convert.ToChangedFile(diff.Files[i], pr.HeadRepo, endCommitID)) } diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index 82cbb3e763c..17bb2085b65 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -12,11 +12,11 @@ import ( "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" @@ -92,7 +92,7 @@ func ListPullReviews(ctx *context.APIContext) { return } - count, err := issues_model.CountReviews(opts) + count, err := issues_model.CountReviews(ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -260,7 +260,7 @@ func DeletePullReview(ctx *context.APIContext) { return } - if err := issues_model.DeleteReview(review); err != nil { + if err := issues_model.DeleteReview(ctx, review); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d", review.ID)) return } @@ -329,8 +329,7 @@ func CreatePullReview(ctx *context.APIContext) { // if CommitID is empty, set it as lastCommitID if opts.CommitID == "" { - - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.Issue.Repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.Issue.Repo) if err != nil { ctx.Error(http.StatusInternalServerError, "git.OpenRepository", err) return @@ -363,6 +362,7 @@ func CreatePullReview(ctx *context.APIContext) { true, // pending review 0, // no reply opts.CommitID, + nil, ); err != nil { ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err) return @@ -545,7 +545,7 @@ func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues return nil, nil, true } - // validate the the review is for the given PR + // validate the review is for the given PR if review.IssueID != pr.IssueID { ctx.NotFound("ReviewNotInPR") return nil, nil, true @@ -640,6 +640,8 @@ func DeleteReviewRequests(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "422": // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" opts := web.GetForm(ctx).(*api.PullReviewRequestOptions) @@ -708,12 +710,16 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions for _, reviewer := range reviewers { comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd) if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) return } if comment != nil && isAdd { - if err = comment.LoadReview(); err != nil { + if err = comment.LoadReview(ctx); err != nil { ctx.ServerError("ReviewRequest", err) return } @@ -757,7 +763,7 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions } if comment != nil && isAdd { - if err = comment.LoadReview(); err != nil { + if err = comment.LoadReview(ctx); err != nil { ctx.ServerError("ReviewRequest", err) return } @@ -874,7 +880,7 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors ctx.Error(http.StatusForbidden, "", "Must be repo admin") return } - review, pr, isWrong := prepareSingleReview(ctx) + review, _, isWrong := prepareSingleReview(ctx) if isWrong { return } @@ -884,13 +890,12 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors return } - if pr.Issue.IsClosed { - ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because this pr is closed") - return - } - _, err := pull_service.DismissReview(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors) if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err) return } diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index be9c0cd00ac..f0f3c0bbc79 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -4,16 +4,18 @@ package repo import ( + "fmt" "net/http" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" release_service "code.gitea.io/gitea/services/release" ) @@ -49,13 +51,12 @@ func GetRelease(ctx *context.APIContext) { // "$ref": "#/responses/notFound" id := ctx.ParamsInt64(":id") - release, err := repo_model.GetReleaseByID(ctx, id) + release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) if err != nil && !repo_model.IsErrReleaseNotExist(err) { - ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) return } - if err != nil && repo_model.IsErrReleaseNotExist(err) || - release.IsTag || release.RepoID != ctx.Repo.Repository.ID { + if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag { ctx.NotFound() return } @@ -90,7 +91,7 @@ func GetLatestRelease(ctx *context.APIContext) { // "$ref": "#/responses/Release" // "404": // "$ref": "#/responses/notFound" - release, err := repo_model.GetLatestReleaseByRepoID(ctx.Repo.Repository.ID) + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil && !repo_model.IsErrReleaseNotExist(err) { ctx.Error(http.StatusInternalServerError, "GetLatestRelease", err) return @@ -134,11 +135,6 @@ func ListReleases(ctx *context.APIContext) { // in: query // description: filter (exclude / include) pre-releases // type: boolean - // - name: per_page - // in: query - // description: page size of results, deprecated - use limit - // type: integer - // deprecated: true // - name: page // in: query // description: page number of results to return (1-based) @@ -153,9 +149,6 @@ func ListReleases(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) - if listOptions.PageSize == 0 && ctx.FormInt("per_page") != 0 { - listOptions.PageSize = ctx.FormInt("per_page") - } opts := repo_model.FindReleasesOptions{ ListOptions: listOptions, @@ -163,9 +156,10 @@ func ListReleases(ctx *context.APIContext) { IncludeTags: false, IsDraft: ctx.FormOptionalBool("draft"), IsPreRelease: ctx.FormOptionalBool("pre-release"), + RepoID: ctx.Repo.Repository.ID, } - releases, err := repo_model.GetReleasesByRepoID(ctx, ctx.Repo.Repository.ID, opts) + releases, err := db.Find[repo_model.Release](ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetReleasesByRepoID", err) return @@ -179,7 +173,7 @@ func ListReleases(ctx *context.APIContext) { rels[i] = convert.ToAPIRelease(ctx, ctx.Repo.Repository, release) } - filteredCount, err := repo_model.CountReleasesByRepoID(ctx.Repo.Repository.ID, opts) + filteredCount, err := db.Count[repo_model.Release](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -222,7 +216,11 @@ func CreateRelease(ctx *context.APIContext) { // "409": // "$ref": "#/responses/error" form := web.GetForm(ctx).(*api.CreateReleaseOption) - rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, form.TagName) + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) + return + } + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) if err != nil { if !repo_model.IsErrReleaseNotExist(err) { ctx.Error(http.StatusInternalServerError, "GetRelease", err) @@ -269,7 +267,7 @@ func CreateRelease(ctx *context.APIContext) { rel.Publisher = ctx.Doer rel.Target = form.Target - if err = release_service.UpdateRelease(ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil { + if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) return } @@ -315,13 +313,12 @@ func EditRelease(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.EditReleaseOption) id := ctx.ParamsInt64(":id") - rel, err := repo_model.GetReleaseByID(ctx, id) + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) if err != nil && !repo_model.IsErrReleaseNotExist(err) { - ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) return } - if err != nil && repo_model.IsErrReleaseNotExist(err) || - rel.IsTag || rel.RepoID != ctx.Repo.Repository.ID { + if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag { ctx.NotFound() return } @@ -344,7 +341,7 @@ func EditRelease(ctx *context.APIContext) { if form.IsPrerelease != nil { rel.IsPrerelease = *form.IsPrerelease } - if err := release_service.UpdateRelease(ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil { + if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) return } @@ -393,17 +390,16 @@ func DeleteRelease(ctx *context.APIContext) { // "$ref": "#/responses/empty" id := ctx.ParamsInt64(":id") - rel, err := repo_model.GetReleaseByID(ctx, id) + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) if err != nil && !repo_model.IsErrReleaseNotExist(err) { - ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) return } - if err != nil && repo_model.IsErrReleaseNotExist(err) || - rel.IsTag || rel.RepoID != ctx.Repo.Repository.ID { + if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag { ctx.NotFound() return } - if err := release_service.DeleteReleaseByID(ctx, id, ctx.Doer, false); err != nil { + if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, false); err != nil { if models.IsErrProtectedTagName(err) { ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") return diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index e1421831287..59fd83e3a27 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -4,19 +4,38 @@ package repo import ( + "io" "net/http" + "strings" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/convert" ) +func checkReleaseMatchRepo(ctx *context.APIContext, releaseID int64) bool { + release, err := repo_model.GetReleaseByID(ctx, releaseID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return false + } + ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + return false + } + if release.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return false + } + return true +} + // GetReleaseAttachment gets a single attachment of the release func GetReleaseAttachment(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoGetReleaseAttachment @@ -54,6 +73,10 @@ func GetReleaseAttachment(ctx *context.APIContext) { // "$ref": "#/responses/notFound" releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + attachID := ctx.ParamsInt64(":attachment_id") attach, err := repo_model.GetAttachmentByID(ctx, attachID) if err != nil { @@ -133,6 +156,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // - application/json // consumes: // - multipart/form-data + // - application/octet-stream // parameters: // - name: owner // in: path @@ -159,7 +183,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // in: formData // description: attachment to upload // type: file - // required: true + // required: false // responses: // "201": // "$ref": "#/responses/Attachment" @@ -176,34 +200,44 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // Check if release exists an load release releaseID := ctx.ParamsInt64(":id") - release, err := repo_model.GetReleaseByID(ctx, releaseID) - if err != nil { - if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound() - return - } - ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + if !checkReleaseMatchRepo(ctx, releaseID) { return } // Get uploaded file from request - file, header, err := ctx.Req.FormFile("attachment") - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetFile", err) - return + var content io.ReadCloser + var filename string + var size int64 = -1 + + if strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") { + file, header, err := ctx.Req.FormFile("attachment") + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFile", err) + return + } + defer file.Close() + + content = file + size = header.Size + filename = header.Filename + if name := ctx.FormString("name"); name != "" { + filename = name + } + } else { + content = ctx.Req.Body + filename = ctx.FormString("name") } - defer file.Close() - filename := header.Filename - if query := ctx.FormString("name"); query != "" { - filename = query + if filename == "" { + ctx.Error(http.StatusBadRequest, "CreateReleaseAttachment", "Could not determine name of attachment.") + return } // Create a new attachment and save the file - attach, err := attachment.UploadAttachment(file, setting.Repository.Release.AllowedTypes, header.Size, &repo_model.Attachment{ + attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ Name: filename, UploaderID: ctx.Doer.ID, - RepoID: release.RepoID, + RepoID: ctx.Repo.Repository.ID, ReleaseID: releaseID, }) if err != nil { @@ -264,6 +298,10 @@ func EditReleaseAttachment(ctx *context.APIContext) { // Check if release exists an load release releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + attachID := ctx.ParamsInt64(":attachment_id") attach, err := repo_model.GetAttachmentByID(ctx, attachID) if err != nil { @@ -328,6 +366,10 @@ func DeleteReleaseAttachment(ctx *context.APIContext) { // Check if release exists an load release releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + attachID := ctx.ParamsInt64(":attachment_id") attach, err := repo_model.GetAttachmentByID(ctx, attachID) if err != nil { diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go index a03edfafcf5..fec91164a29 100644 --- a/routers/api/v1/repo/release_tags.go +++ b/routers/api/v1/repo/release_tags.go @@ -8,7 +8,7 @@ import ( "code.gitea.io/gitea/models" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" releaseservice "code.gitea.io/gitea/services/release" ) @@ -44,7 +44,7 @@ func GetReleaseByTag(ctx *context.APIContext) { tag := ctx.Params(":tag") - release, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tag) + release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound() @@ -97,7 +97,7 @@ func DeleteReleaseByTag(ctx *context.APIContext) { tag := ctx.Params(":tag") - release, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tag) + release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound() @@ -112,7 +112,7 @@ func DeleteReleaseByTag(ctx *context.APIContext) { return } - if err = releaseservice.DeleteReleaseByID(ctx, release.ID, ctx.Doer, false); err != nil { + if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, release, ctx.Doer, false); err != nil { if models.IsErrProtectedTagName(err) { ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") return diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index e06fc2df664..822e368fa84 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -8,9 +8,11 @@ import ( "fmt" "net/http" "slices" + "strconv" "strings" "time" + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" @@ -19,17 +21,19 @@ import ( repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/issue" repo_service "code.gitea.io/gitea/services/repository" @@ -133,33 +137,33 @@ func Search(ctx *context.APIContext) { PriorityOwnerID: ctx.FormInt64("priority_owner_id"), TeamID: ctx.FormInt64("team_id"), TopicOnly: ctx.FormBool("topic"), - Collaborate: util.OptionalBoolNone, + Collaborate: optional.None[bool](), Private: ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")), - Template: util.OptionalBoolNone, + Template: optional.None[bool](), StarredByID: ctx.FormInt64("starredBy"), IncludeDescription: ctx.FormBool("includeDesc"), } if ctx.FormString("template") != "" { - opts.Template = util.OptionalBoolOf(ctx.FormBool("template")) + opts.Template = optional.Some(ctx.FormBool("template")) } if ctx.FormBool("exclusive") { - opts.Collaborate = util.OptionalBoolFalse + opts.Collaborate = optional.Some(false) } mode := ctx.FormString("mode") switch mode { case "source": - opts.Fork = util.OptionalBoolFalse - opts.Mirror = util.OptionalBoolFalse + opts.Fork = optional.Some(false) + opts.Mirror = optional.Some(false) case "fork": - opts.Fork = util.OptionalBoolTrue + opts.Fork = optional.Some(true) case "mirror": - opts.Mirror = util.OptionalBoolTrue + opts.Mirror = optional.Some(true) case "collaborative": - opts.Mirror = util.OptionalBoolFalse - opts.Collaborate = util.OptionalBoolTrue + opts.Mirror = optional.Some(false) + opts.Collaborate = optional.Some(true) case "": default: ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid search mode: \"%s\"", mode)) @@ -167,11 +171,11 @@ func Search(ctx *context.APIContext) { } if ctx.FormString("archived") != "" { - opts.Archived = util.OptionalBoolOf(ctx.FormBool("archived")) + opts.Archived = optional.Some(ctx.FormBool("archived")) } if ctx.FormString("is_private") != "" { - opts.IsPrivate = util.OptionalBoolOf(ctx.FormBool("is_private")) + opts.IsPrivate = optional.Some(ctx.FormBool("is_private")) } sortMode := ctx.FormString("sort") @@ -242,17 +246,18 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre } repo, err := repo_service.CreateRepository(ctx, ctx.Doer, owner, repo_service.CreateRepoOptions{ - Name: opt.Name, - Description: opt.Description, - IssueLabels: opt.IssueLabels, - Gitignores: opt.Gitignores, - License: opt.License, - Readme: opt.Readme, - IsPrivate: opt.Private, - AutoInit: opt.AutoInit, - DefaultBranch: opt.DefaultBranch, - TrustModel: repo_model.ToTrustModel(opt.TrustModel), - IsTemplate: opt.Template, + Name: opt.Name, + Description: opt.Description, + IssueLabels: opt.IssueLabels, + Gitignores: opt.Gitignores, + License: opt.License, + Readme: opt.Readme, + IsPrivate: opt.Private, + AutoInit: opt.AutoInit, + DefaultBranch: opt.DefaultBranch, + TrustModel: repo_model.ToTrustModel(opt.TrustModel), + IsTemplate: opt.Template, + ObjectFormatName: opt.ObjectFormatName, }) if err != nil { if repo_model.IsErrRepoAlreadyExist(err) { @@ -355,7 +360,7 @@ func Generate(ctx *context.APIContext) { return } - opts := repo_module.GenerateRepoOptions{ + opts := repo_service.GenerateRepoOptions{ Name: form.Name, DefaultBranch: form.DefaultBranch, Description: form.Description, @@ -396,7 +401,7 @@ func Generate(ctx *context.APIContext) { } if !ctx.Doer.IsAdmin { - canCreate, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx.Doer.ID) + canCreate, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("CanCreateOrgRepo", err) return @@ -502,7 +507,7 @@ func CreateOrgRepo(ctx *context.APIContext) { } if !ctx.Doer.IsAdmin { - canCreate, err := org.CanCreateOrgRepo(ctx.Doer.ID) + canCreate, err := org.CanCreateOrgRepo(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err) return @@ -636,7 +641,7 @@ func Edit(ctx *context.APIContext) { } } - if opts.MirrorInterval != nil { + if opts.MirrorInterval != nil || opts.EnablePrune != nil { if err := updateMirror(ctx, opts); err != nil { return } @@ -717,7 +722,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err if ctx.Repo.GitRepo == nil && !repo.IsEmpty { var err error - ctx.Repo.GitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, repo) if err != nil { ctx.Error(http.StatusInternalServerError, "Unable to OpenRepository", err) return err @@ -728,7 +733,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err // Default branch only updated if changed and exist or the repository is empty if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || ctx.Repo.GitRepo.IsBranchExist(*opts.DefaultBranch)) { if !repo.IsEmpty { - if err := ctx.Repo.GitRepo.SetDefaultBranch(*opts.DefaultBranch); err != nil { + if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, *opts.DefaultBranch); err != nil { if !git.IsErrUnsupportedVersion(err) { ctx.Error(http.StatusInternalServerError, "SetDefaultBranch", err) return err @@ -882,6 +887,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { AllowRebase: true, AllowRebaseMerge: true, AllowSquash: true, + AllowFastForwardOnly: true, AllowManualMerge: true, AutodetectManualMerge: false, AllowRebaseUpdate: true, @@ -908,6 +914,9 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { if opts.AllowSquash != nil { config.AllowSquash = *opts.AllowSquash } + if opts.AllowFastForwardOnly != nil { + config.AllowFastForwardOnly = *opts.AllowFastForwardOnly + } if opts.AllowManualMerge != nil { config.AllowManualMerge = *opts.AllowManualMerge } @@ -937,13 +946,33 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { } } - if opts.HasProjects != nil && !unit_model.TypeProjects.UnitGlobalDisabled() { - if *opts.HasProjects { + currHasProjects := repo.UnitEnabled(ctx, unit_model.TypeProjects) + newHasProjects := currHasProjects + if opts.HasProjects != nil { + newHasProjects = *opts.HasProjects + } + if currHasProjects || newHasProjects { + if newHasProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { + unit, err := repo.GetUnit(ctx, unit_model.TypeProjects) + var config *repo_model.ProjectsConfig + if err != nil { + config = &repo_model.ProjectsConfig{ + ProjectsMode: repo_model.ProjectsModeAll, + } + } else { + config = unit.ProjectsConfig() + } + + if opts.ProjectsMode != nil { + config.ProjectsMode = repo_model.ProjectsMode(*opts.ProjectsMode) + } + units = append(units, repo_model.RepoUnit{ RepoID: repo.ID, Type: unit_model.TypeProjects, + Config: config, }) - } else { + } else if !newHasProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) } } @@ -982,7 +1011,7 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { } if len(units)+len(deleteUnitTypes) > 0 { - if err := repo_model.UpdateRepositoryUnits(repo, units, deleteUnitTypes); err != nil { + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateRepositoryUnits", err) return err } @@ -1003,18 +1032,26 @@ func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) e return err } if *opts.Archived { - if err := repo_model.SetArchiveRepoState(repo, *opts.Archived); err != nil { + if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil { log.Error("Tried to archive a repo: %s", err) ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) return err } + if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) } else { - if err := repo_model.SetArchiveRepoState(repo, *opts.Archived); err != nil { + if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil { log.Error("Tried to un-archive a repo: %s", err) ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) return err } + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) } } @@ -1114,7 +1151,7 @@ func Delete(ctx *context.APIContext) { owner := ctx.Repo.Owner repo := ctx.Repo.Repository - canDelete, err := repo_module.CanUserDelete(repo, ctx.Doer) + canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer) if err != nil { ctx.Error(http.StatusInternalServerError, "CanUserDelete", err) return @@ -1159,12 +1196,11 @@ func GetIssueTemplates(ctx *context.APIContext) { // "$ref": "#/responses/IssueTemplates" // "404": // "$ref": "#/responses/notFound" - ret, err := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetTemplatesFromDefaultBranch", err) - return + ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + if cnt := len(ret.TemplateErrors); cnt != 0 { + ctx.Resp.Header().Add("X-Gitea-Warning", "error occurs when parsing issue template: count="+strconv.Itoa(cnt)) } - ctx.JSON(http.StatusOK, ret) + ctx.JSON(http.StatusOK, ret.IssueTemplates) } // GetIssueConfig returns the issue config for a repo diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go index 29e2d1f21d9..8d6ca9e3b54 100644 --- a/routers/api/v1/repo/repo_test.go +++ b/routers/api/v1/repo/repo_test.go @@ -9,9 +9,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -35,6 +35,7 @@ func TestRepoEdit(t *testing.T) { allowRebase := false allowRebaseMerge := false allowSquashMerge := false + allowFastForwardOnlyMerge := false archived := true opts := api.EditRepoOption{ Name: &ctx.Repo.Repository.Name, @@ -50,6 +51,7 @@ func TestRepoEdit(t *testing.T) { AllowRebase: &allowRebase, AllowRebaseMerge: &allowRebaseMerge, AllowSquash: &allowSquashMerge, + AllowFastForwardOnly: &allowFastForwardOnlyMerge, Archived: &archived, } diff --git a/routers/api/v1/repo/runners.go b/routers/api/v1/repo/runners.go new file mode 100644 index 00000000000..fe133b311d5 --- /dev/null +++ b/routers/api/v1/repo/runners.go @@ -0,0 +1,34 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// GetRegistrationToken returns the token to register repo runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/runners/registration-token repository repoGetRunnerRegistrationToken + // --- + // summary: Get a repository's actions runner registration token + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID) +} diff --git a/routers/api/v1/repo/star.go b/routers/api/v1/repo/star.go index 05227e33a0c..99676de119c 100644 --- a/routers/api/v1/repo/star.go +++ b/routers/api/v1/repo/star.go @@ -7,9 +7,9 @@ import ( "net/http" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go index 926d91ca814..9e36ea0aed3 100644 --- a/routers/api/v1/repo/status.go +++ b/routers/api/v1/repo/status.go @@ -7,13 +7,14 @@ import ( "fmt" "net/http" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" - files_service "code.gitea.io/gitea/services/repository/files" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" ) // NewCommitStatus creates a new CommitStatus @@ -63,7 +64,7 @@ func NewCommitStatus(ctx *context.APIContext) { Description: form.Description, Context: form.Context, } - if err := files_service.CreateCommitStatus(ctx, ctx.Repo.Repository, ctx.Doer, sha, status); err != nil { + if err := commitstatus_service.CreateCommitStatus(ctx, ctx.Repo.Repository, ctx.Doer, sha, status); err != nil { ctx.Error(http.StatusInternalServerError, "CreateCommitStatus", err) return } @@ -194,8 +195,10 @@ func getCommitStatuses(ctx *context.APIContext, sha string) { listOptions := utils.GetListOptions(ctx) - statuses, maxResults, err := git_model.GetCommitStatuses(ctx, repo, sha, &git_model.CommitStatusOptions{ + statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{ ListOptions: listOptions, + RepoID: repo.ID, + SHA: sha, SortType: ctx.FormTrim("sort"), State: ctx.FormTrim("state"), }) diff --git a/routers/api/v1/repo/subscriber.go b/routers/api/v1/repo/subscriber.go index 05509fc4435..85841828578 100644 --- a/routers/api/v1/repo/subscriber.go +++ b/routers/api/v1/repo/subscriber.go @@ -7,9 +7,9 @@ import ( "net/http" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index 0c0a73698f7..a6908f36157 100644 --- a/routers/api/v1/repo/tag.go +++ b/routers/api/v1/repo/tag.go @@ -10,10 +10,10 @@ import ( "code.gitea.io/gitea/models" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" releaseservice "code.gitea.io/gitea/services/release" ) @@ -184,6 +184,8 @@ func CreateTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/conflict" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateTagOption) // If target is not provided use default branch @@ -251,9 +253,11 @@ func DeleteTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/conflict" + // "423": + // "$ref": "#/responses/repoArchivedError" tagName := ctx.Params("*") - tag, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) + tag, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound() @@ -268,7 +272,7 @@ func DeleteTag(ctx *context.APIContext) { return } - if err = releaseservice.DeleteReleaseByID(ctx, tag.ID, ctx.Doer, true); err != nil { + if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, tag, ctx.Doer, true); err != nil { if models.IsErrProtectedTagName(err) { ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") return diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go index 1bacc712118..0ecf3a39d8f 100644 --- a/routers/api/v1/repo/teams.go +++ b/routers/api/v1/repo/teams.go @@ -8,7 +8,7 @@ import ( "net/http" "code.gitea.io/gitea/models/organization" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go index c0c05154c47..9852caa9896 100644 --- a/routers/api/v1/repo/topic.go +++ b/routers/api/v1/repo/topic.go @@ -7,12 +7,13 @@ import ( "net/http" "strings" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -53,7 +54,7 @@ func ListTopics(ctx *context.APIContext) { RepoID: ctx.Repo.Repository.ID, } - topics, total, err := repo_model.FindTopics(opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -120,7 +121,7 @@ func UpdateTopics(ctx *context.APIContext) { return } - err := repo_model.SaveTopics(ctx.Repo.Repository.ID, validTopics...) + err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...) if err != nil { log.Error("SaveTopics failed: %v", err) ctx.InternalServerError(err) @@ -172,7 +173,7 @@ func AddTopic(ctx *context.APIContext) { } // Prevent adding more topics than allowed to repo - count, err := repo_model.CountTopics(&repo_model.FindTopicOptions{ + count, err := db.Count[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ RepoID: ctx.Repo.Repository.ID, }) if err != nil { @@ -187,7 +188,7 @@ func AddTopic(ctx *context.APIContext) { return } - _, err = repo_model.AddTopic(ctx.Repo.Repository.ID, topicName) + _, err = repo_model.AddTopic(ctx, ctx.Repo.Repository.ID, topicName) if err != nil { log.Error("AddTopic failed: %v", err) ctx.InternalServerError(err) @@ -238,7 +239,7 @@ func DeleteTopic(ctx *context.APIContext) { return } - topic, err := repo_model.DeleteTopic(ctx.Repo.Repository.ID, topicName) + topic, err := repo_model.DeleteTopic(ctx, ctx.Repo.Repository.ID, topicName) if err != nil { log.Error("DeleteTopic failed: %v", err) ctx.InternalServerError(err) @@ -287,7 +288,7 @@ func TopicSearch(ctx *context.APIContext) { ListOptions: utils.GetListOptions(ctx), } - topics, total, err := repo_model.FindTopics(opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.InternalServerError(err) return diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 8ff22a1193f..776b336761f 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "fmt" "net/http" @@ -13,10 +14,10 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" repo_service "code.gitea.io/gitea/services/repository" ) @@ -68,7 +69,7 @@ func Transfer(ctx *context.APIContext) { } if newOwner.Type == user_model.UserTypeOrganization { - if !ctx.Doer.IsAdmin && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx.Doer.ID) { + if !ctx.Doer.IsAdmin && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { // The user shouldn't know about this organization ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found") return @@ -117,7 +118,11 @@ func Transfer(ctx *context.APIContext) { return } - ctx.InternalServerError(err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.InternalServerError(err) + } return } @@ -221,7 +226,7 @@ func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { return err } - if !repoTransfer.CanUserAcceptTransfer(ctx.Doer) { + if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil) return fmt.Errorf("user does not have permissions to do this") } @@ -230,5 +235,5 @@ func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { return repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) } - return models.CancelRepositoryTransfer(ctx.Repo.Repository) + return repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository) } diff --git a/routers/api/v1/repo/tree.go b/routers/api/v1/repo/tree.go index f63100b6ea2..353a996d5b0 100644 --- a/routers/api/v1/repo/tree.go +++ b/routers/api/v1/repo/tree.go @@ -6,7 +6,7 @@ package repo import ( "net/http" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" files_service "code.gitea.io/gitea/services/repository/files" ) diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index 4ea3fbd11d6..f18ea087c4d 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -10,12 +10,13 @@ import ( "net/url" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" wiki_service "code.gitea.io/gitea/services/wiki" @@ -52,6 +53,8 @@ func NewWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateWikiPageOptions) @@ -128,6 +131,8 @@ func EditWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateWikiPageOptions) @@ -198,7 +203,7 @@ func getWikiPage(ctx *context.APIContext, wikiName wiki_service.WebPath) *api.Wi } return &api.WikiPage{ - WikiPageMetaData: convert.ToWikiPageMetaData(wikiName, lastCommit, ctx.Repo.Repository), + WikiPageMetaData: wiki_service.ToWikiPageMetaData(wikiName, lastCommit, ctx.Repo.Repository), ContentBase64: content, CommitCount: commitsCount, Sidebar: sidebarContent, @@ -234,6 +239,8 @@ func DeleteWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) @@ -326,7 +333,7 @@ func ListWikiPages(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "WikiFilenameToName", err) return } - pages = append(pages, convert.ToWikiPageMetaData(wikiName, c, ctx.Repo.Repository)) + pages = append(pages, wiki_service.ToWikiPageMetaData(wikiName, c, ctx.Repo.Repository)) } ctx.SetTotalCountHeader(int64(len(entries))) @@ -469,7 +476,7 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) // findWikiRepoCommit opens the wiki repo and returns the latest commit, writing to context on error. // The caller is responsible for closing the returned repo again func findWikiRepoCommit(ctx *context.APIContext) (*git.Repository, *git.Commit) { - wikiRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.WikiPath()) + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) if err != nil { if git.IsErrNotExist(err) || err.Error() == "no such file or directory" { diff --git a/routers/api/v1/settings/settings.go b/routers/api/v1/settings/settings.go index 02bda1309d7..0ee81b96d5b 100644 --- a/routers/api/v1/settings/settings.go +++ b/routers/api/v1/settings/settings.go @@ -6,9 +6,9 @@ package settings import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" ) // GetGeneralUISettings returns instance's global settings for ui diff --git a/routers/api/v1/shared/block.go b/routers/api/v1/shared/block.go new file mode 100644 index 00000000000..a1e65625ed3 --- /dev/null +++ b/routers/api/v1/shared/block.go @@ -0,0 +1,98 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "errors" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" +) + +func ListBlocks(ctx *context.APIContext, blocker *user_model.User) { + blocks, total, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ + ListOptions: utils.GetListOptions(ctx), + BlockerID: blocker.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindBlockings", err) + return + } + + if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + users := make([]*api.User, 0, len(blocks)) + for _, b := range blocks { + users = append(users, convert.ToUser(ctx, b.Blockee, blocker)) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &users) +} + +func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + status := http.StatusNotFound + blocking, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBlocking", err) + return + } + if blocking != nil { + status = http.StatusNoContent + } + + ctx.Status(status) +} + +func BlockUser(ctx *context.APIContext, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, ctx.FormString("note")); err != nil { + if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Error(http.StatusBadRequest, "BlockUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "BlockUser", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +func UnblockUser(ctx *context.APIContext, doer, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + if err := user_service.UnblockUser(ctx, doer, blocker, blockee); err != nil { + if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Error(http.StatusBadRequest, "UnblockUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "UnblockUser", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go new file mode 100644 index 00000000000..c850ad7866a --- /dev/null +++ b/routers/api/v1/shared/runners.go @@ -0,0 +1,32 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// RegistrationToken is response related to registeration token +// swagger:response RegistrationToken +type RegistrationToken struct { + Token string `json:"token"` +} + +func GetRegistrationToken(ctx *context.APIContext, ownerID, repoID int64) { + token, err := actions_model.GetLatestRunnerToken(ctx, ownerID, repoID) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { + token, err = actions_model.NewRunnerToken(ctx, ownerID, repoID) + } + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, RegistrationToken{Token: token.Token}) +} diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index 37717807180..665f4d0b852 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -18,3 +18,17 @@ type swaggerResponseSecret struct { // in:body Body api.Secret `json:"body"` } + +// ActionVariable +// swagger:response ActionVariable +type swaggerResponseActionVariable struct { + // in:body + Body api.ActionVariable `json:"body"` +} + +// VariableList +// swagger:response VariableList +type swaggerResponseVariableList struct { + // in:body + Body []api.ActionVariable `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 6f7859df62e..cd551cbdfa9 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -190,4 +190,13 @@ type swaggerParameterBodies struct { // in:body CreateOrUpdateSecretOption api.CreateOrUpdateSecretOption + + // in:body + UserBadgeOption api.UserBadgeOption + + // in:body + CreateVariableOption api.CreateVariableOption + + // in:body + UpdateVariableOption api.UpdateVariableOption } diff --git a/routers/api/v1/swagger/user.go b/routers/api/v1/swagger/user.go index fb6d185ee7d..e2ad511d2b9 100644 --- a/routers/api/v1/swagger/user.go +++ b/routers/api/v1/swagger/user.go @@ -48,3 +48,10 @@ type swaggerResponseUserSettings struct { // in:body Body []api.UserSettings `json:"body"` } + +// BadgeList +// swagger:response BadgeList +type swaggerResponseBadgeList struct { + // in:body + Body []api.Badge `json:"body"` +} diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index cbe332a7798..bf78c2c8649 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -7,10 +7,14 @@ import ( "errors" "net/http" - "code.gitea.io/gitea/modules/context" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" secret_service "code.gitea.io/gitea/services/secrets" ) @@ -101,3 +105,249 @@ func DeleteSecret(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// CreateVariable create a user-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /user/actions/variables/{variablename} user createUserVariable + // --- + // summary: Create a user-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating a variable + // "204": + // description: response when creating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Doer.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a user-level variable which is created by current doer +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /user/actions/variables/{variablename} user updateUserVariable + // --- + // summary: Update a user-level variable which is created by current doer + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a variable + // "204": + // description: response when updating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteVariable delete a user-level variable which is created by current doer +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/variables/{variablename} user deleteUserVariable + // --- + // summary: Delete a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Doer.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetVariable get a user-level variable which is created by current doer +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables/{variablename} user getUserVariable + // --- + // summary: Get a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// ListVariables list user-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables user getUserVariablesList + // --- + // summary: Get the user-level list of variables which is created by current doer + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index e512ba9e4bd..88e314ed31b 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -12,10 +12,11 @@ import ( "strings" auth_model "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -43,15 +44,12 @@ func ListAccessTokens(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/AccessTokenList" + // "403": + // "$ref": "#/responses/forbidden" - opts := auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID, ListOptions: utils.GetListOptions(ctx)} + opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: utils.GetListOptions(ctx)} - count, err := auth_model.CountAccessTokens(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - tokens, err := auth_model.ListAccessTokens(ctx, opts) + tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -95,11 +93,13 @@ func CreateAccessToken(ctx *context.APIContext) { // "$ref": "#/responses/AccessToken" // "400": // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" form := web.GetForm(ctx).(*api.CreateAccessTokenOption) t := &auth_model.AccessToken{ - UID: ctx.Doer.ID, + UID: ctx.ContextUser.ID, Name: form.Name, } @@ -153,6 +153,8 @@ func DeleteAccessToken(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": @@ -162,9 +164,9 @@ func DeleteAccessToken(ctx *context.APIContext) { tokenID, _ := strconv.ParseInt(token, 0, 64) if tokenID == 0 { - tokens, err := auth_model.ListAccessTokens(ctx, auth_model.ListAccessTokensOptions{ + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{ Name: token, - UserID: ctx.Doer.ID, + UserID: ctx.ContextUser.ID, }) if err != nil { ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err) @@ -187,7 +189,7 @@ func DeleteAccessToken(ctx *context.APIContext) { return } - if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.Doer.ID); err != nil { + if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { if auth_model.IsErrAccessTokenNotExist(err) { ctx.NotFound() } else { @@ -230,7 +232,7 @@ func CreateOauth2Application(ctx *context.APIContext) { ctx.Error(http.StatusBadRequest, "", "error creating oauth2 application") return } - secret, err := app.GenerateClientSecret() + secret, err := app.GenerateClientSecret(ctx) if err != nil { ctx.Error(http.StatusBadRequest, "", "error creating application secret") return @@ -260,7 +262,10 @@ func ListOauth2Applications(ctx *context.APIContext) { // "200": // "$ref": "#/responses/OAuth2ApplicationList" - apps, total, err := auth_model.ListOAuth2Applications(ctx.Doer.ID, utils.GetListOptions(ctx)) + apps, total, err := db.FindAndCount[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ + ListOptions: utils.GetListOptions(ctx), + OwnerID: ctx.Doer.ID, + }) if err != nil { ctx.Error(http.StatusInternalServerError, "ListOAuth2Applications", err) return @@ -296,7 +301,7 @@ func DeleteOauth2Application(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" appID := ctx.ParamsInt64(":id") - if err := auth_model.DeleteOAuth2Application(appID, ctx.Doer.ID); err != nil { + if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil { if auth_model.IsErrOAuthApplicationNotFound(err) { ctx.NotFound() } else { @@ -337,6 +342,10 @@ func GetOauth2Application(ctx *context.APIContext) { } return } + if app.UID != ctx.Doer.ID { + ctx.NotFound() + return + } app.ClientSecret = "" @@ -371,7 +380,7 @@ func UpdateOauth2Application(ctx *context.APIContext) { data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions) - app, err := auth_model.UpdateOAuth2Application(auth_model.UpdateOAuth2ApplicationOptions{ + app, err := auth_model.UpdateOAuth2Application(ctx, auth_model.UpdateOAuth2ApplicationOptions{ Name: data.Name, UserID: ctx.Doer.ID, ID: appID, @@ -386,7 +395,7 @@ func UpdateOauth2Application(ctx *context.APIContext) { } return } - app.ClientSecret, err = app.GenerateClientSecret() + app.ClientSecret, err = app.GenerateClientSecret(ctx) if err != nil { ctx.Error(http.StatusBadRequest, "", "error updating application secret") return diff --git a/routers/api/v1/user/avatar.go b/routers/api/v1/user/avatar.go index 84fa129b13a..f912296228e 100644 --- a/routers/api/v1/user/avatar.go +++ b/routers/api/v1/user/avatar.go @@ -7,9 +7,9 @@ import ( "encoding/base64" "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" user_service "code.gitea.io/gitea/services/user" ) @@ -36,7 +36,7 @@ func UpdateAvatar(ctx *context.APIContext) { return } - err = user_service.UploadAvatar(ctx.Doer, content) + err = user_service.UploadAvatar(ctx, ctx.Doer, content) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) } @@ -54,7 +54,7 @@ func DeleteAvatar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" - err := user_service.DeleteAvatar(ctx.Doer) + err := user_service.DeleteAvatar(ctx, ctx.Doer) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) } diff --git a/routers/api/v1/user/block.go b/routers/api/v1/user/block.go new file mode 100644 index 00000000000..7231e9add7a --- /dev/null +++ b/routers/api/v1/user/block.go @@ -0,0 +1,96 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +func ListBlocks(ctx *context.APIContext) { + // swagger:operation GET /user/blocks user userListBlocks + // --- + // summary: List users blocked by the authenticated user + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserList" + + shared.ListBlocks(ctx, ctx.Doer) +} + +func CheckUserBlock(ctx *context.APIContext) { + // swagger:operation GET /user/blocks/{username} user userCheckUserBlock + // --- + // summary: Check if a user is blocked by the authenticated user + // parameters: + // - name: username + // in: path + // description: user to check + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + shared.CheckUserBlock(ctx, ctx.Doer) +} + +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/blocks/{username} user userBlockUser + // --- + // summary: Block a user + // parameters: + // - name: username + // in: path + // description: user to block + // type: string + // required: true + // - name: note + // in: query + // description: optional note for the block + // type: string + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.BlockUser(ctx, ctx.Doer) +} + +func UnblockUser(ctx *context.APIContext) { + // swagger:operation DELETE /user/blocks/{username} user userUnblockUser + // --- + // summary: Unblock a user + // parameters: + // - name: username + // in: path + // description: user to unblock + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.UnblockUser(ctx, ctx.Doer, ctx.Doer) +} diff --git a/routers/api/v1/user/email.go b/routers/api/v1/user/email.go index 68f6c974a59..33aa851a807 100644 --- a/routers/api/v1/user/email.go +++ b/routers/api/v1/user/email.go @@ -8,11 +8,11 @@ import ( "net/http" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" ) // ListEmails list all of the authenticated user's email addresses @@ -56,22 +56,14 @@ func AddEmail(ctx *context.APIContext) { // "$ref": "#/responses/EmailList" // "422": // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.CreateEmailOption) if len(form.Emails) == 0 { ctx.Error(http.StatusUnprocessableEntity, "", "Email list empty") return } - emails := make([]*user_model.EmailAddress, len(form.Emails)) - for i := range form.Emails { - emails[i] = &user_model.EmailAddress{ - UID: ctx.Doer.ID, - Email: form.Emails[i], - IsActivated: !setting.Service.RegisterEmailConfirm, - } - } - - if err := user_model.AddEmailAddresses(ctx, emails); err != nil { + if err := user_service.AddEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { if user_model.IsErrEmailAlreadyUsed(err) { ctx.Error(http.StatusUnprocessableEntity, "", "Email address has been used: "+err.(user_model.ErrEmailAlreadyUsed).Email) } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { @@ -91,11 +83,17 @@ func AddEmail(ctx *context.APIContext) { return } - apiEmails := make([]*api.Email, len(emails)) - for i := range emails { - apiEmails[i] = convert.ToEmail(emails[i]) + emails, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetEmailAddresses", err) + return } - ctx.JSON(http.StatusCreated, &apiEmails) + + apiEmails := make([]*api.Email, 0, len(emails)) + for _, email := range emails { + apiEmails = append(apiEmails, convert.ToEmail(email)) + } + ctx.JSON(http.StatusCreated, apiEmails) } // DeleteEmail delete email @@ -115,26 +113,19 @@ func DeleteEmail(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.DeleteEmailOption) if len(form.Emails) == 0 { ctx.Status(http.StatusNoContent) return } - emails := make([]*user_model.EmailAddress, len(form.Emails)) - for i := range form.Emails { - emails[i] = &user_model.EmailAddress{ - Email: form.Emails[i], - UID: ctx.Doer.ID, - } - } - - if err := user_model.DeleteEmailAddresses(ctx, emails); err != nil { + if err := user_service.DeleteEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { if user_model.IsErrEmailAddressNotExist(err) { ctx.Error(http.StatusNotFound, "DeleteEmailAddresses", err) - return + } else { + ctx.Error(http.StatusInternalServerError, "DeleteEmailAddresses", err) } - ctx.Error(http.StatusInternalServerError, "DeleteEmailAddresses", err) return } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 1aa906ccb11..6abb70de194 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -5,12 +5,13 @@ package user import ( + "errors" "net/http" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -151,7 +152,7 @@ func ListFollowing(ctx *context.APIContext) { } func checkUserFollowing(ctx *context.APIContext, u *user_model.User, followID int64) { - if user_model.IsFollowing(u.ID, followID) { + if user_model.IsFollowing(ctx, u.ID, followID) { ctx.Status(http.StatusNoContent) } else { ctx.NotFound() @@ -221,11 +222,17 @@ func Follow(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { - ctx.Error(http.StatusInternalServerError, "FollowUser", err) + if err := user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "FollowUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "FollowUser", err) + } return } ctx.Status(http.StatusNoContent) @@ -248,7 +255,7 @@ func Unfollow(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "UnfollowUser", err) return } diff --git a/routers/api/v1/user/gpg_key.go b/routers/api/v1/user/gpg_key.go index 4aebbaf3142..5a2f995e1b5 100644 --- a/routers/api/v1/user/gpg_key.go +++ b/routers/api/v1/user/gpg_key.go @@ -10,31 +10,35 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) func listGPGKeys(ctx *context.APIContext, uid int64, listOptions db.ListOptions) { - keys, err := asymkey_model.ListGPGKeys(ctx, uid, listOptions) + keys, total, err := db.FindAndCount[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: listOptions, + OwnerID: uid, + }) if err != nil { ctx.Error(http.StatusInternalServerError, "ListGPGKeys", err) return } + if err := asymkey_model.GPGKeyList(keys).LoadSubKeys(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "ListGPGKeys", err) + return + } + apiKeys := make([]*api.GPGKey, len(keys)) for i := range keys { apiKeys[i] = convert.ToGPGKey(keys[i]) } - total, err := asymkey_model.CountUserGPGKeys(uid) - if err != nil { - ctx.InternalServerError(err) - return - } - ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, &apiKeys) } @@ -112,7 +116,7 @@ func GetGPGKey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - key, err := asymkey_model.GetGPGKeyByID(ctx.ParamsInt64(":id")) + key, err := asymkey_model.GetGPGKeyForUserByID(ctx, ctx.Doer.ID, ctx.ParamsInt64(":id")) if err != nil { if asymkey_model.IsErrGPGKeyNotExist(err) { ctx.NotFound() @@ -121,17 +125,26 @@ func GetGPGKey(ctx *context.APIContext) { } return } + if err := key.LoadSubKeys(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadSubKeys", err) + return + } ctx.JSON(http.StatusOK, convert.ToGPGKey(key)) } // CreateUserGPGKey creates new GPG key to given user by ID. func CreateUserGPGKey(ctx *context.APIContext, form api.CreateGPGKeyOption, uid int64) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + token := asymkey_model.VerificationToken(ctx.Doer, 1) lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) - keys, err := asymkey_model.AddGPGKey(uid, form.ArmoredKey, token, form.Signature) + keys, err := asymkey_model.AddGPGKey(ctx, uid, form.ArmoredKey, token, form.Signature) if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { - keys, err = asymkey_model.AddGPGKey(uid, form.ArmoredKey, lastToken, form.Signature) + keys, err = asymkey_model.AddGPGKey(ctx, uid, form.ArmoredKey, lastToken, form.Signature) } if err != nil { HandleAddGPGKeyError(ctx, err, token) @@ -185,9 +198,9 @@ func VerifyUserGPGKey(ctx *context.APIContext) { return } - _, err := asymkey_model.VerifyGPGKey(ctx.Doer.ID, form.KeyID, token, form.Signature) + _, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature) if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { - _, err = asymkey_model.VerifyGPGKey(ctx.Doer.ID, form.KeyID, lastToken, form.Signature) + _, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature) } if err != nil { @@ -198,7 +211,10 @@ func VerifyUserGPGKey(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "VerifyUserGPGKey", err) } - key, err := asymkey_model.GetGPGKeysByKeyID(form.KeyID) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + KeyID: form.KeyID, + IncludeSubKeys: true, + }) if err != nil { if asymkey_model.IsErrGPGKeyNotExist(err) { ctx.NotFound() @@ -207,7 +223,7 @@ func VerifyUserGPGKey(ctx *context.APIContext) { } return } - ctx.JSON(http.StatusOK, convert.ToGPGKey(key[0])) + ctx.JSON(http.StatusOK, convert.ToGPGKey(keys[0])) } // swagger:parameters userCurrentPostGPGKey @@ -259,7 +275,12 @@ func DeleteGPGKey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := asymkey_model.DeleteGPGKey(ctx.Doer, ctx.ParamsInt64(":id")); err != nil { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + + if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.ParamsInt64(":id")); err != nil { if asymkey_model.IsErrGPGKeyAccessDenied(err) { ctx.Error(http.StatusForbidden, "", "You do not have access to this key") } else { diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go index 4b642910b19..8b5c64e2919 100644 --- a/routers/api/v1/user/helper.go +++ b/routers/api/v1/user/helper.go @@ -7,7 +7,7 @@ import ( "net/http" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) // GetUserByParamsName get user by name @@ -16,7 +16,7 @@ func GetUserByParamsName(ctx *context.APIContext, name string) *user_model.User user, err := user_model.GetUserByName(ctx, username) if err != nil { if user_model.IsErrUserNotExist(err) { - if redirectUserID, err2 := user_model.LookupUserRedirect(username); err2 == nil { + if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil { context.RedirectToUser(ctx.Base, username, redirectUserID) } else { ctx.NotFound("GetUserByName", err) diff --git a/routers/api/v1/user/hook.go b/routers/api/v1/user/hook.go index 50be519c815..9d9ca5bf010 100644 --- a/routers/api/v1/user/hook.go +++ b/routers/api/v1/user/hook.go @@ -6,10 +6,10 @@ package user import ( "net/http" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" ) @@ -62,6 +62,11 @@ func GetHook(ctx *context.APIContext) { return } + if !ctx.Doer.IsAdmin && hook.OwnerID != ctx.Doer.ID { + ctx.NotFound() + return + } + apiHook, err := webhook_service.ToHook(ctx.Doer.HomeLink(), hook) if err != nil { ctx.InternalServerError(err) diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index 21cc560847b..d9456e7ec60 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -5,18 +5,20 @@ package user import ( std_ctx "context" + "fmt" "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -56,25 +58,26 @@ func listPublicKeys(ctx *context.APIContext, user *user_model.User) { username := ctx.Params("username") if fingerprint != "" { + var userID int64 // Unrestricted // Querying not just listing if username != "" { // Restrict to provided uid - keys, err = asymkey_model.SearchPublicKey(user.ID, fingerprint) - } else { - // Unrestricted - keys, err = asymkey_model.SearchPublicKey(0, fingerprint) + userID = user.ID } + keys, err = db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: userID, + Fingerprint: fingerprint, + }) count = len(keys) } else { - total, err2 := asymkey_model.CountPublicKeys(user.ID) - if err2 != nil { - ctx.InternalServerError(err) - return - } - count = int(total) - + var total int64 // Use ListPublicKeys - keys, err = asymkey_model.ListPublicKeys(user.ID, utils.GetListOptions(ctx)) + keys, total, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + ListOptions: utils.GetListOptions(ctx), + OwnerID: user.ID, + NotKeytype: asymkey_model.KeyTypePrincipal, + }) + count = int(total) } if err != nil { @@ -176,7 +179,7 @@ func GetPublicKey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - key, err := asymkey_model.GetPublicKeyByID(ctx.ParamsInt64(":id")) + key, err := asymkey_model.GetPublicKeyByID(ctx, ctx.ParamsInt64(":id")) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { ctx.NotFound() @@ -196,13 +199,18 @@ func GetPublicKey(ctx *context.APIContext) { // CreateUserPublicKey creates new public key to given user by ID. func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid int64) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + content, err := asymkey_model.CheckPublicKeyString(form.Key) if err != nil { repo.HandleCheckKeyStringError(ctx, err) return } - key, err := asymkey_model.AddPublicKey(uid, form.Title, content, 0) + key, err := asymkey_model.AddPublicKey(ctx, uid, form.Title, content, 0) if err != nil { repo.HandleAddKeyError(ctx, err) return @@ -261,8 +269,13 @@ func DeletePublicKey(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + id := ctx.ParamsInt64(":id") - externallyManaged, err := asymkey_model.PublicKeyIsExternallyManaged(id) + externallyManaged, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, id) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { ctx.NotFound() @@ -277,7 +290,7 @@ func DeletePublicKey(ctx *context.APIContext) { return } - if err := asymkey_service.DeletePublicKey(ctx.Doer, id); err != nil { + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, id); err != nil { if asymkey_model.IsErrKeyAccessDenied(err) { ctx.Error(http.StatusForbidden, "", "You do not have access to this key") } else { diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index 26ebf89746d..81f8e0f3fe9 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -11,9 +11,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -21,7 +21,7 @@ import ( func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { opts := utils.GetListOptions(ctx) - repos, count, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + repos, count, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: u, Private: private, ListOptions: opts, diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go new file mode 100644 index 00000000000..899218473ee --- /dev/null +++ b/routers/api/v1/user/runners.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization + +// GetRegistrationToken returns the token to register user runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /user/actions/runners/registration-token user userGetRunnerRegistrationToken + // --- + // summary: Get an user's actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0) +} diff --git a/routers/api/v1/user/settings.go b/routers/api/v1/user/settings.go index 53794c82f83..d0a8daaa85b 100644 --- a/routers/api/v1/user/settings.go +++ b/routers/api/v1/user/settings.go @@ -6,11 +6,12 @@ package user import ( "net/http" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" ) // GetUserSettings returns user settings @@ -44,36 +45,18 @@ func UpdateUserSettings(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.UserSettingsOptions) - if form.FullName != nil { - ctx.Doer.FullName = *form.FullName + opts := &user_service.UpdateOptions{ + FullName: optional.FromPtr(form.FullName), + Description: optional.FromPtr(form.Description), + Website: optional.FromPtr(form.Website), + Location: optional.FromPtr(form.Location), + Language: optional.FromPtr(form.Language), + Theme: optional.FromPtr(form.Theme), + DiffViewStyle: optional.FromPtr(form.DiffViewStyle), + KeepEmailPrivate: optional.FromPtr(form.HideEmail), + KeepActivityPrivate: optional.FromPtr(form.HideActivity), } - if form.Description != nil { - ctx.Doer.Description = *form.Description - } - if form.Website != nil { - ctx.Doer.Website = *form.Website - } - if form.Location != nil { - ctx.Doer.Location = *form.Location - } - if form.Language != nil { - ctx.Doer.Language = *form.Language - } - if form.Theme != nil { - ctx.Doer.Theme = *form.Theme - } - if form.DiffViewStyle != nil { - ctx.Doer.DiffViewStyle = *form.DiffViewStyle - } - - if form.HideEmail != nil { - ctx.Doer.KeepEmailPrivate = *form.HideEmail - } - if form.HideActivity != nil { - ctx.Doer.KeepActivityPrivate = *form.HideActivity - } - - if err := user_model.UpdateUser(ctx, ctx.Doer, false); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { ctx.InternalServerError(err) return } diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 2659789ddd9..ad9ed9548d0 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -5,23 +5,26 @@ package user import ( - std_context "context" + "errors" "net/http" - "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) // getStarredRepos returns the repos that the user with the specified userID has // starred -func getStarredRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, error) { - starredRepos, err := repo_model.GetStarredRepos(ctx, user.ID, private, listOptions) +func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { + starredRepos, err := repo_model.GetStarredRepos(ctx, &repo_model.StarredReposOptions{ + ListOptions: utils.GetListOptions(ctx), + StarrerID: user.ID, + IncludePrivate: private, + }) if err != nil { return nil, err } @@ -65,7 +68,7 @@ func GetStarredRepos(ctx *context.APIContext) { // "$ref": "#/responses/notFound" private := ctx.ContextUser.ID == ctx.Doer.ID - repos, err := getStarredRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + repos, err := getStarredRepos(ctx, ctx.ContextUser, private) if err != nil { ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) return @@ -95,7 +98,7 @@ func GetMyStarredRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" - repos, err := getStarredRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + repos, err := getStarredRepos(ctx, ctx.Doer, true) if err != nil { ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) } @@ -152,12 +155,18 @@ func Star(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - err := repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "StarRepo", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + } return } ctx.Status(http.StatusNoContent) @@ -185,7 +194,7 @@ func Unstar(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) if err != nil { ctx.Error(http.StatusInternalServerError, "StarRepo", err) return diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 251e7a60139..09147cd2ae0 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -9,8 +9,8 @@ import ( activities_model "code.gitea.io/gitea/models/activities" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -54,19 +54,33 @@ func Search(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ - Actor: ctx.Doer, - Keyword: ctx.FormTrim("q"), - UID: ctx.FormInt64("uid"), - Type: user_model.UserTypeIndividual, - ListOptions: listOptions, - }) - if err != nil { - ctx.JSON(http.StatusInternalServerError, map[string]any{ - "ok": false, - "error": err.Error(), + uid := ctx.FormInt64("uid") + var users []*user_model.User + var maxResults int64 + var err error + + switch uid { + case user_model.GhostUserID: + maxResults = 1 + users = []*user_model.User{user_model.NewGhostUser()} + case user_model.ActionsUserID: + maxResults = 1 + users = []*user_model.User{user_model.NewActionsUser()} + default: + users, maxResults, err = user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + UID: uid, + Type: user_model.UserTypeIndividual, + ListOptions: listOptions, }) - return + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": err.Error(), + }) + return + } } ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) @@ -138,7 +152,7 @@ func GetUserHeatmapData(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx.ContextUser, ctx.Doer) + heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) if err != nil { ctx.Error(http.StatusInternalServerError, "GetUserHeatmapDataByUser", err) return diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 7f531eafaa6..2cc23ae4763 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -4,22 +4,25 @@ package user import ( - std_context "context" + "errors" "net/http" - "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) // getWatchedRepos returns the repos that the user with the specified userID is watching -func getWatchedRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, int64, error) { - watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, user.ID, private, listOptions) +func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, int64, error) { + watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, &repo_model.WatchedReposOptions{ + ListOptions: utils.GetListOptions(ctx), + WatcherID: user.ID, + IncludePrivate: private, + }) if err != nil { return nil, 0, err } @@ -63,7 +66,7 @@ func GetWatchedRepos(ctx *context.APIContext) { // "$ref": "#/responses/notFound" private := ctx.ContextUser.ID == ctx.Doer.ID - repos, total, err := getWatchedRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + repos, total, err := getWatchedRepos(ctx, ctx.ContextUser, private) if err != nil { ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) } @@ -92,7 +95,7 @@ func GetMyWatchedRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" - repos, total, err := getWatchedRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + repos, total, err := getWatchedRepos(ctx, ctx.Doer, true) if err != nil { ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) } @@ -157,12 +160,18 @@ func Watch(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/WatchInfo" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + } return } ctx.JSON(http.StatusOK, api.WatchInfo{ @@ -197,7 +206,7 @@ func Unwatch(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) if err != nil { ctx.Error(http.StatusInternalServerError, "UnwatchRepo", err) return diff --git a/routers/api/v1/utils/git.go b/routers/api/v1/utils/git.go index 32f5c85319d..4e25137817e 100644 --- a/routers/api/v1/utils/git.go +++ b/routers/api/v1/utils/git.go @@ -8,9 +8,10 @@ import ( "fmt" "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) // ResolveRefOrSha resolve ref to sha if exist @@ -69,27 +70,28 @@ func searchRefCommitByType(ctx *context.APIContext, refType, filter string) (str return "", "", nil } -// ConvertToSHA1 returns a full-length SHA1 from a potential ID string -func ConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) (git.SHA1, error) { - if len(commitID) == git.SHAFullLength && git.IsValidSHAPattern(commitID) { - sha1, err := git.NewIDFromString(commitID) +// ConvertToObjectID returns a full-length SHA1 from a potential ID string +func ConvertToObjectID(ctx gocontext.Context, repo *context.Repository, commitID string) (git.ObjectID, error) { + objectFormat := repo.GetObjectFormat() + if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) { + sha, err := git.NewIDFromString(commitID) if err == nil { - return sha1, nil + return sha, nil } } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.Repository.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo.Repository) if err != nil { - return git.SHA1{}, fmt.Errorf("RepositoryFromContextOrOpen: %w", err) + return objectFormat.EmptyObjectID(), fmt.Errorf("RepositoryFromContextOrOpen: %w", err) } defer closer.Close() - return gitRepo.ConvertToSHA1(commitID) + return gitRepo.ConvertToGitID(commitID) } // MustConvertToSHA1 returns a full-length SHA1 string from a potential ID string, or returns origin input if it can't convert to SHA1 func MustConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) string { - sha, err := ConvertToSHA1(ctx, repo, commitID) + sha, err := ConvertToObjectID(ctx, repo, commitID) if err != nil { return commitID } diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index b62d20a18a6..f1abd49a7d6 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -6,16 +6,18 @@ package utils import ( "fmt" "net/http" + "strconv" "strings" + "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" ) @@ -26,13 +28,7 @@ func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { OwnerID: owner.ID, } - count, err := webhook.CountWebhooksByOpts(opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - hooks, err := webhook.ListWebhooksByOpts(ctx, opts) + hooks, count, err := db.FindAndCount[webhook.Webhook](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -53,7 +49,7 @@ func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { // GetOwnerHook gets an user or organization webhook. Errors are written to ctx. func GetOwnerHook(ctx *context.APIContext, ownerID, hookID int64) (*webhook.Webhook, error) { - w, err := webhook.GetWebhookByOwnerID(ownerID, hookID) + w, err := webhook.GetWebhookByOwnerID(ctx, ownerID, hookID) if err != nil { if webhook.IsErrWebhookNotExist(err) { ctx.NotFound() @@ -68,7 +64,7 @@ func GetOwnerHook(ctx *context.APIContext, ownerID, hookID int64) (*webhook.Webh // GetRepoHook get a repo's webhook. If there is an error, write to `ctx` // accordingly and return the error func GetRepoHook(ctx *context.APIContext, repoID, hookID int64) (*webhook.Webhook, error) { - w, err := webhook.GetWebhookByRepoID(repoID, hookID) + w, err := webhook.GetWebhookByRepoID(ctx, repoID, hookID) if err != nil { if webhook.IsErrWebhookNotExist(err) { ctx.NotFound() @@ -162,6 +158,7 @@ func pullHook(events []string, event string) bool { // addHook add the hook specified by `form`, `ownerID` and `repoID`. If there is // an error, write to `ctx` accordingly. Return (webhook, ok) func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoID int64) (*webhook.Webhook, bool) { + var isSystemWebhook bool if !checkCreateHookOption(ctx, form) { return nil, false } @@ -169,13 +166,22 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI if len(form.Events) == 0 { form.Events = []string{"push"} } + if form.Config["is_system_webhook"] != "" { + sw, err := strconv.ParseBool(form.Config["is_system_webhook"]) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid is_system_webhook value") + return nil, false + } + isSystemWebhook = sw + } w := &webhook.Webhook{ - OwnerID: ownerID, - RepoID: repoID, - URL: form.Config["url"], - ContentType: webhook.ToHookContentType(form.Config["content_type"]), - Secret: form.Config["secret"], - HTTPMethod: "POST", + OwnerID: ownerID, + RepoID: repoID, + URL: form.Config["url"], + ContentType: webhook.ToHookContentType(form.Config["content_type"]), + Secret: form.Config["secret"], + HTTPMethod: "POST", + IsSystemWebhook: isSystemWebhook, HookEvent: &webhook_module.HookEvent{ ChooseEvents: true, HookEvents: webhook_module.HookEvents{ @@ -392,7 +398,7 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh w.IsActive = *form.Active } - if err := webhook.UpdateWebhook(w); err != nil { + if err := webhook.UpdateWebhook(ctx, w); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateWebhook", err) return false } @@ -401,7 +407,7 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh // DeleteOwnerHook deletes the hook owned by the owner. func DeleteOwnerHook(ctx *context.APIContext, owner *user_model.User, hookID int64) { - if err := webhook.DeleteWebhookByOwnerID(owner.ID, hookID); err != nil { + if err := webhook.DeleteWebhookByOwnerID(ctx, owner.ID, hookID); err != nil { if webhook.IsErrWebhookNotExist(err) { ctx.NotFound() } else { diff --git a/routers/api/v1/utils/page.go b/routers/api/v1/utils/page.go index 6910b829319..024ba7b8d92 100644 --- a/routers/api/v1/utils/page.go +++ b/routers/api/v1/utils/page.go @@ -5,7 +5,7 @@ package utils import ( "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/common/auth.go b/routers/common/auth.go index 8904785d51f..115d65ed104 100644 --- a/routers/common/auth.go +++ b/routers/common/auth.go @@ -5,9 +5,9 @@ package common import ( user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" ) type AuthResult struct { diff --git a/routers/common/db.go b/routers/common/db.go index 2e86fbd0fd4..a67c9582fa3 100644 --- a/routers/common/db.go +++ b/routers/common/db.go @@ -10,8 +10,10 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/migrations" + system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" "xorm.io/xorm" ) @@ -35,7 +37,7 @@ func InitDBEngine(ctx context.Context) (err error) { log.Info("Backing off for %d seconds", int64(setting.Database.DBConnectBackoff/time.Second)) time.Sleep(setting.Database.DBConnectBackoff) } - db.HasEngine = true + config.SetDynGetter(system_model.NewDatabaseDynKeyGetter()) return nil } diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 9c8ccc3388d..402ca44c12d 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/services/context" ) const tplStatus500 base.TplName = "status/500" @@ -35,20 +36,18 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { httpcache.SetCacheControlInHeader(w.Header(), 0, "no-transform") w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) - data := middleware.GetContextData(req.Context()) - if data["locale"] == nil { - data = middleware.CommonTemplateContextData() - data["locale"] = middleware.Locale(w, req) - } + tmplCtx := context.TemplateContext{} + tmplCtx["Locale"] = middleware.Locale(w, req) + ctxData := middleware.GetContextData(req.Context()) // This recovery handler could be called without Gitea's web context, so we shouldn't touch that context too much. // Otherwise, the 500-page may cause new panics, eg: cache.GetContextWithData, it makes the developer&users couldn't find the original panic. - user, _ := data[middleware.ContextDataKeySignedUser].(*user_model.User) + user, _ := ctxData[middleware.ContextDataKeySignedUser].(*user_model.User) if !setting.IsProd || (user != nil && user.IsAdmin) { - data["ErrorMsg"] = "PANIC: " + combinedErr + ctxData["ErrorMsg"] = "PANIC: " + combinedErr } - err = templates.HTMLRenderer().HTML(w, http.StatusInternalServerError, string(tplStatus500), data, nil) + err = templates.HTMLRenderer().HTML(w, http.StatusInternalServerError, string(tplStatus500), ctxData, tmplCtx) if err != nil { log.Error("Error occurs again when rendering error page: %v", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/routers/common/errpage_test.go b/routers/common/errpage_test.go index ea9a9e745c1..4fd63ba49e7 100644 --- a/routers/common/errpage_test.go +++ b/routers/common/errpage_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -27,6 +26,7 @@ func TestRenderPanicErrorPage(t *testing.T) { respContent := w.Body.String() assert.Contains(t, respContent, `class="page-content status-page-500"`) assert.Contains(t, respContent, ``) + assert.Contains(t, respContent, `lang="en-US"`) // make sure the locale work // the 500 page doesn't have normal pages footer, it makes it easier to distinguish a normal page and a failed page. // especially when a sub-template causes page error, the HTTP response code is still 200, @@ -35,7 +35,5 @@ func TestRenderPanicErrorPage(t *testing.T) { } func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/common/markup.go b/routers/common/markup.go index 5f412014d7e..2d5638ef612 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -9,11 +9,11 @@ import ( "net/http" "strings" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "mvdan.cc/xurls/v2" ) @@ -32,8 +32,11 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr case "markdown": // Raw markdown if err := markdown.RenderRaw(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: urlPrefix, + }, }, strings.NewReader(text), ctx.Resp); err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) } @@ -65,9 +68,9 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr meta := map[string]string{} if repo != nil && repo.Repository != nil { if mode == "comment" { - meta = repo.Repository.ComposeMetas() + meta = repo.Repository.ComposeMetas(ctx) } else { - meta = repo.Repository.ComposeDocumentMetas() + meta = repo.Repository.ComposeDocumentMetas(ctx) } } if mode != "comment" { @@ -75,8 +78,11 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr } if err := markup.Render(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: urlPrefix, + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: urlPrefix, + }, Metas: meta, IsWiki: wiki, Type: markupType, diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 8a39dda1798..c7c75fb099b 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -9,11 +9,11 @@ import ( "strings" "code.gitea.io/gitea/modules/cache" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/session" "github.com/chi-middleware/proxy" @@ -38,6 +38,7 @@ func ProtocolMiddlewares() (handlers []any) { }) }) + // wrap the request and response, use the process context and add it to the process manager handlers = append(handlers, func(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { ctx, _, finished := process.GetManager().AddTypedContext(req.Context(), fmt.Sprintf("%s: %s", req.Method, req.RequestURI), process.RequestProcessType, true) diff --git a/routers/common/redirect.go b/routers/common/redirect.go index 9bf2025e19e..34044e814bc 100644 --- a/routers/common/redirect.go +++ b/routers/common/redirect.go @@ -17,7 +17,7 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) { // The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2", // then frontend needs this delegate to redirect to the new location with hash correctly. redirect := req.PostFormValue("redirect") - if httplib.IsRiskyRedirectURL(redirect) { + if !httplib.IsCurrentGiteaSiteURL(redirect) { resp.WriteHeader(http.StatusBadRequest) return } diff --git a/routers/common/serve.go b/routers/common/serve.go index 8a7f8b33321..446908db75d 100644 --- a/routers/common/serve.go +++ b/routers/common/serve.go @@ -7,11 +7,11 @@ import ( "io" "time" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) // ServeBlob download a git.Blob diff --git a/routers/init.go b/routers/init.go index 6369a39754b..aaf95920c2e 100644 --- a/routers/init.go +++ b/routers/init.go @@ -9,7 +9,6 @@ import ( "runtime" "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" authmodel "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/eventsource" @@ -33,6 +32,7 @@ import ( "code.gitea.io/gitea/routers/private" web_routers "code.gitea.io/gitea/routers/web" actions_service "code.gitea.io/gitea/services/actions" + asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/automerge" @@ -45,6 +45,7 @@ import ( repo_migrations "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" pull_service "code.gitea.io/gitea/services/pull" + release_service "code.gitea.io/gitea/services/release" repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/services/repository/archiver" "code.gitea.io/gitea/services/task" @@ -72,7 +73,7 @@ func mustInitCtx(ctx context.Context, fn func(ctx context.Context) error) { func syncAppConfForGit(ctx context.Context) error { runtimeState := new(system.RuntimeState) - if err := system.AppState.Get(runtimeState); err != nil { + if err := system.AppState.Get(ctx, runtimeState); err != nil { return err } @@ -93,9 +94,9 @@ func syncAppConfForGit(ctx context.Context) error { mustInitCtx(ctx, repo_service.SyncRepositoryHooks) log.Info("re-write ssh public keys ...") - mustInit(asymkey_model.RewriteAllPublicKeys) + mustInitCtx(ctx, asymkey_service.RewriteAllPublicKeys) - return system.AppState.Set(runtimeState) + return system.AppState.Set(ctx, runtimeState) } return nil } @@ -118,10 +119,10 @@ func InitWebInstalled(ctx context.Context) { mustInit(storage.Init) mailer.NewContext(ctx) - mustInit(cache.NewContext) + mustInit(cache.Init) mustInit(feed_service.Init) mustInit(uinotification.Init) - mustInit(archiver.Init) + mustInitCtx(ctx, archiver.Init) highlight.NewContext() external.RegisterRenderers() @@ -136,11 +137,13 @@ func InitWebInstalled(ctx context.Context) { mustInitCtx(ctx, common.InitDBEngine) log.Info("ORM engine initialization successful!") mustInit(system.Init) - mustInit(oauth2.Init) + mustInitCtx(ctx, oauth2.Init) + + mustInit(release_service.Init) mustInitCtx(ctx, models.Init) mustInitCtx(ctx, authmodel.Init) - mustInit(repo_service.Init) + mustInitCtx(ctx, repo_service.Init) // Booting long running goroutines. mustInit(indexer_service.Init) @@ -195,6 +198,8 @@ func NormalRoutes() *web.Route { // TODO: this prefix should be generated with a token string with runner ? prefix = "/api/actions_pipeline" r.Mount(prefix, actions_router.ArtifactsRoutes(prefix)) + prefix = actions_router.ArtifactV4RouteBase + r.Mount(prefix, actions_router.ArtifactsV4Routes(prefix)) } return r diff --git a/routers/install/install.go b/routers/install/install.go index 3eb16b9ce85..9c6a8849b63 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -7,6 +7,7 @@ package install import ( "fmt" "net/http" + "net/mail" "os" "os/exec" "path/filepath" @@ -21,18 +22,20 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password/hash" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/generate" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/user" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/routers/common" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "gitea.com/go-chi/session" @@ -407,7 +410,7 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("server").Key("LFS_START_SERVER").SetValue("true") cfg.Section("lfs").Key("PATH").SetValue(form.LFSRootPath) var lfsJwtSecret string - if _, lfsJwtSecret, err = generate.NewJwtSecretBase64(); err != nil { + if _, lfsJwtSecret, err = generate.NewJwtSecretWithBase64(); err != nil { ctx.RenderWithErr(ctx.Tr("install.lfs_jwt_secret_failed", err), tplInstall, &form) return } @@ -417,6 +420,11 @@ func SubmitInstall(ctx *context.Context) { } if len(strings.TrimSpace(form.SMTPAddr)) > 0 { + if _, err := mail.ParseAddress(form.SMTPFrom); err != nil { + ctx.RenderWithErr(ctx.Tr("install.smtp_from_invalid"), tplInstall, &form) + return + } + cfg.Section("mailer").Key("ENABLED").SetValue("true") cfg.Section("mailer").Key("SMTP_ADDR").SetValue(form.SMTPAddr) cfg.Section("mailer").Key("SMTP_PORT").SetValue(form.SMTPPort) @@ -430,15 +438,14 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("service").Key("ENABLE_NOTIFY_MAIL").SetValue(fmt.Sprint(form.MailNotify)) cfg.Section("server").Key("OFFLINE_MODE").SetValue(fmt.Sprint(form.OfflineMode)) - // if you are reinstalling, this maybe not right because of missing version - if err := system_model.SetSettingNoVersion(ctx, system_model.KeyPictureDisableGravatar, strconv.FormatBool(form.DisableGravatar)); err != nil { - ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) - return - } - if err := system_model.SetSettingNoVersion(ctx, system_model.KeyPictureEnableFederatedAvatar, strconv.FormatBool(form.EnableFederatedAvatar)); err != nil { + if err := system_model.SetSettings(ctx, map[string]string{ + setting.Config().Picture.DisableGravatar.DynKey(): strconv.FormatBool(form.DisableGravatar), + setting.Config().Picture.EnableFederatedAvatar.DynKey(): strconv.FormatBool(form.EnableFederatedAvatar), + }); err != nil { ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } + cfg.Section("openid").Key("ENABLE_OPENID_SIGNIN").SetValue(fmt.Sprint(form.EnableOpenIDSignIn)) cfg.Section("openid").Key("ENABLE_OPENID_SIGNUP").SetValue(fmt.Sprint(form.EnableOpenIDSignUp)) cfg.Section("service").Key("DISABLE_REGISTRATION").SetValue(fmt.Sprint(form.DisableRegistration)) @@ -532,8 +539,8 @@ func SubmitInstall(ctx *context.Context) { IsAdmin: true, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsRestricted: util.OptionalBoolFalse, - IsActive: util.OptionalBoolTrue, + IsRestricted: optional.Some(false), + IsActive: optional.Some(true), } if err = user_model.CreateUser(ctx, u, overwriteDefault); err != nil { @@ -548,11 +555,13 @@ func SubmitInstall(ctx *context.Context) { u, _ = user_model.GetUserByName(ctx, u.Name) } - days := 86400 * setting.LogInRememberDays - ctx.SetSiteCookie(setting.CookieUserName, u.Name, days) + nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("CreateAuthTokenForUserID", err) + return + } - ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), - setting.CookieRememberName, u.Name, days) + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) // Auto-login for admin if err = ctx.Session.Set("uid", u.ID); err != nil { diff --git a/routers/install/routes_test.go b/routers/install/routes_test.go index fcbd0529774..2aa7f5d7b73 100644 --- a/routers/install/routes_test.go +++ b/routers/install/routes_test.go @@ -5,7 +5,6 @@ package install import ( "net/http/httptest" - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -35,7 +34,5 @@ func TestRoutes(t *testing.T) { } func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/private/actions.go b/routers/private/actions.go index 2403b9c41a0..696634b5e75 100644 --- a/routers/private/actions.go +++ b/routers/private/actions.go @@ -12,11 +12,11 @@ import ( actions_model "code.gitea.io/gitea/models/actions" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) // GenerateActionsRunnerToken generates a new runner token for a given scope @@ -26,7 +26,7 @@ func GenerateActionsRunnerToken(ctx *context.PrivateContext) { defer rd.Close() if err := json.NewDecoder(rd).Decode(&genRequest); err != nil { - log.Error("%v", err) + log.Error("JSON Decode failed: %v", err) ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), }) @@ -35,28 +35,28 @@ func GenerateActionsRunnerToken(ctx *context.PrivateContext) { owner, repo, err := parseScope(ctx, genRequest.Scope) if err != nil { - log.Error("%v", err) + log.Error("parseScope failed: %v", err) ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), }) } - token, err := actions_model.GetUnactivatedRunnerToken(ctx, owner, repo) - if errors.Is(err, util.ErrNotExist) { + token, err := actions_model.GetLatestRunnerToken(ctx, owner, repo) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { token, err = actions_model.NewRunnerToken(ctx, owner, repo) if err != nil { - err := fmt.Sprintf("error while creating runner token: %v", err) - log.Error("%v", err) + errMsg := fmt.Sprintf("error while creating runner token: %v", err) + log.Error("NewRunnerToken failed: %v", errMsg) ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err, + Err: errMsg, }) return } } else if err != nil { - err := fmt.Sprintf("could not get unactivated runner token: %v", err) - log.Error("%v", err) + errMsg := fmt.Sprintf("could not get unactivated runner token: %v", err) + log.Error("GetLatestRunnerToken failed: %v", errMsg) ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: err, + Err: errMsg, }) return } @@ -83,7 +83,7 @@ func parseScope(ctx *context.PrivateContext, scope string) (ownerID, repoID int6 return ownerID, repoID, nil } - r, err := repo_model.GetRepositoryByName(u.ID, repoName) + r, err := repo_model.GetRepositoryByName(ctx, u.ID, repoName) if err != nil { return ownerID, repoID, err } diff --git a/routers/private/default_branch.go b/routers/private/default_branch.go index a23e101e9d1..33890be6a92 100644 --- a/routers/private/default_branch.go +++ b/routers/private/default_branch.go @@ -8,9 +8,10 @@ import ( "net/http" repo_model "code.gitea.io/gitea/models/repo" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/private" + gitea_context "code.gitea.io/gitea/services/context" ) // SetDefaultBranch updates the default branch @@ -20,7 +21,7 @@ func SetDefaultBranch(ctx *gitea_context.PrivateContext) { branch := ctx.Params(":branch") ctx.Repo.Repository.DefaultBranch = branch - if err := ctx.Repo.GitRepo.SetDefaultBranch(ctx.Repo.Repository.DefaultBranch); err != nil { + if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil { if !git.IsErrUnsupportedVersion(err) { ctx.JSON(http.StatusInternalServerError, private.Response{ Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err), diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 0c9a2a10fa6..769a68970d2 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -6,18 +6,22 @@ package private import ( "fmt" "net/http" - "strconv" + git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - gitea_context "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + gitea_context "code.gitea.io/gitea/services/context" + pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" ) @@ -27,6 +31,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // We don't rely on RepoAssignment here because: // a) we don't need the git repo in this function + // OUT OF DATE: we do need the git repo to sync the branch to the db now. // b) our update function will likely change the repository in the db so we will need to refresh it // c) we don't always need the repo @@ -34,7 +39,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { repoName := ctx.Params(":repo") // defer getting the repository at this point - as we should only retrieve it if we're going to call update - var repo *repo_model.Repository + var ( + repo *repo_model.Repository + gitRepo *git.Repository + ) + defer gitRepo.Close() // it's safe to call Close on a nil pointer updates := make([]*repo_module.PushUpdateOptions, 0, len(opts.OldCommitIDs)) wasEmpty := false @@ -68,6 +77,10 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { updates = append(updates, option) if repo.IsEmpty && (refFullName.BranchName() == "master" || refFullName.BranchName() == "main") { // put the master/main branch first + // FIXME: It doesn't always work, since the master/main branch may not be the first batch of updates. + // If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once. + // See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27 + // If the user executes `git push origin --all` and pushes more than 30 branches, the master/main may not be the default branch. copy(updates[1:], updates) updates[0] = option } @@ -75,6 +88,64 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } if repo != nil && len(updates) > 0 { + branchesToSync := make([]*repo_module.PushUpdateOptions, 0, len(updates)) + for _, update := range updates { + if !update.RefFullName.IsBranch() { + continue + } + if repo == nil { + repo = loadRepository(ctx, ownerName, repoName) + if ctx.Written() { + return + } + wasEmpty = repo.IsEmpty + } + + if update.IsDelRef() { + if err := git_model.AddDeletedBranch(ctx, repo.ID, update.RefFullName.BranchName(), update.PusherID); err != nil { + log.Error("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to add deleted branch: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } else { + branchesToSync = append(branchesToSync, update) + + // TODO: should we return the error and return the error when pushing? Currently it will log the error and not prevent the pushing + pull_service.UpdatePullsRefs(ctx, repo, update) + } + } + if len(branchesToSync) > 0 { + if gitRepo == nil { + var err error + gitRepo, err = gitrepo.OpenRepository(ctx, repo) + if err != nil { + log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + + var ( + branchNames = make([]string, 0, len(branchesToSync)) + commitIDs = make([]string, 0, len(branchesToSync)) + ) + for _, update := range branchesToSync { + branchNames = append(branchNames, update.RefFullName.BranchName()) + commitIDs = append(commitIDs, update.NewCommitID) + } + + if err := repo_service.SyncBranchesToDB(ctx, repo.ID, opts.UserID, branchNames, commitIDs, gitRepo.GetCommit); err != nil { + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to sync branch to DB in repository: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + } + if err := repo_service.PushUpdates(updates); err != nil { log.Error("Failed to Update: %s/%s Total Updates: %d", ownerName, repoName, len(updates)) for i, update := range updates { @@ -89,8 +160,10 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } } + isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate) + isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) // Handle Push Options - if len(opts.GitPushOptions) > 0 { + if isPrivate.Has() || isTemplate.Has() { // load the repository if repo == nil { repo = loadRepository(ctx, ownerName, repoName) @@ -101,13 +174,49 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { wasEmpty = repo.IsEmpty } - repo.IsPrivate = opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate, repo.IsPrivate) - repo.IsTemplate = opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate, repo.IsTemplate) - if err := repo_model.UpdateRepositoryCols(ctx, repo, "is_private", "is_template"); err != nil { + pusher, err := user_model.GetUserByID(ctx, opts.UserID) + if err != nil { log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), }) + return + } + perm, err := access_model.GetUserRepoPermission(ctx, repo, pusher) + if err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } + if !perm.IsOwner() && !perm.IsAdmin() { + ctx.JSON(http.StatusNotFound, private.HookPostReceiveResult{ + Err: "Permissions denied", + }) + return + } + + cols := make([]string, 0, len(opts.GitPushOptions)) + + if isPrivate.Has() { + repo.IsPrivate = isPrivate.Value() + cols = append(cols, "is_private") + } + + if isTemplate.Has() { + repo.IsTemplate = isTemplate.Value() + cols = append(cols, "is_template") + } + + if len(cols) > 0 { + if err := repo_model.UpdateRepositoryCols(ctx, repo, cols...); err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Failed to Update: %s/%s Error: %v", ownerName, repoName, err), + }) + return + } } } @@ -122,45 +231,8 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { refFullName := opts.RefFullNames[i] newCommitID := opts.NewCommitIDs[i] - // post update for agit pull request - // FIXME: use pr.Flow to test whether it's an Agit PR or a GH PR - if git.SupportProcReceive && refFullName.IsPull() { - if repo == nil { - repo = loadRepository(ctx, ownerName, repoName) - if ctx.Written() { - return - } - } - - pullIndex, _ := strconv.ParseInt(refFullName.PullName(), 10, 64) - if pullIndex <= 0 { - continue - } - - pr, err := issues_model.GetPullRequestByIndex(ctx, repo.ID, pullIndex) - if err != nil && !issues_model.IsErrPullRequestNotExist(err) { - log.Error("Failed to get PR by index %v Error: %v", pullIndex, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Failed to get PR by index %v Error: %v", pullIndex, err), - }) - return - } - if pr == nil { - continue - } - - results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), - Create: false, - Branch: "", - URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index), - }) - continue - } - // If we've pushed a branch (and not deleted it) - if newCommitID != git.EmptySHA && refFullName.IsBranch() { - + if !git.IsEmptyCommitID(newCommitID) && refFullName.IsBranch() { // First ensure we have the repository loaded, we're allowed pulls requests and we can get the base repo if repo == nil { repo = loadRepository(ctx, ownerName, repoName) @@ -179,12 +251,12 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { }) return } - if repo.BaseRepo.AllowsPulls() { + if repo.BaseRepo.AllowsPulls(ctx) { baseRepo = repo.BaseRepo } } - if !baseRepo.AllowsPulls() { + if !baseRepo.AllowsPulls(ctx) { // We can stop there's no need to go any further ctx.JSON(http.StatusOK, private.HookPostReceiveResult{ RepoWasEmpty: wasEmpty, @@ -217,14 +289,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch) } results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(), + Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx), Create: true, Branch: branch, URL: fmt.Sprintf("%s/compare/%s...%s", baseRepo.HTMLURL(), util.PathEscapeSegments(baseRepo.DefaultBranch), util.PathEscapeSegments(branch)), }) } else { results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(), + Message: setting.Git.PullRequestPushMessage && baseRepo.AllowsPulls(ctx), Create: false, Branch: branch, URL: fmt.Sprintf("%s/pulls/%d", baseRepo.HTMLURL(), pr.Index), diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 4399e498518..32ec3003e26 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -16,11 +16,11 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/web" + gitea_context "code.gitea.io/gitea/services/context" pull_service "code.gitea.io/gitea/services/pull" ) @@ -122,7 +122,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName) case refFullName.IsTag(): preReceiveTag(ourCtx, oldCommitID, newCommitID, refFullName) - case git.SupportProcReceive && refFullName.IsFor(): + case git.DefaultFeatures.SupportProcReceive && refFullName.IsFor(): preReceiveFor(ourCtx, oldCommitID, newCommitID, refFullName) default: ourCtx.AssertCanWriteCode() @@ -145,8 +145,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r repo := ctx.Repo.Repository gitRepo := ctx.Repo.GitRepo + objectFormat := ctx.Repo.GetObjectFormat() - if branchName == repo.DefaultBranch && newCommitID == git.EmptySHA { + if branchName == repo.DefaultBranch && newCommitID == objectFormat.EmptyObjectID().String() { log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo) ctx.JSON(http.StatusForbidden, private.Response{ UserMsg: fmt.Sprintf("branch %s is the default branch and cannot be deleted", branchName), @@ -174,7 +175,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r // First of all we need to enforce absolutely: // // 1. Detect and prevent deletion of the branch - if newCommitID == git.EmptySHA { + if newCommitID == objectFormat.EmptyObjectID().String() { log.Warn("Forbidden: Branch: %s in %-v is protected from deletion", branchName, repo) ctx.JSON(http.StatusForbidden, private.Response{ UserMsg: fmt.Sprintf("branch %s is protected from deletion", branchName), @@ -183,7 +184,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r } // 2. Disallow force pushes to protected branches - if git.EmptySHA != oldCommitID { + if oldCommitID != objectFormat.EmptyObjectID().String() { output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+newCommitID).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: ctx.env}) if err != nil { log.Error("Unable to detect force push between: %s and %s in %-v Error: %v", oldCommitID, newCommitID, repo, err) diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go index 55771207706..cee3bbdd121 100644 --- a/routers/private/hook_proc_receive.go +++ b/routers/private/hook_proc_receive.go @@ -7,18 +7,18 @@ import ( "net/http" repo_model "code.gitea.io/gitea/models/repo" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/agit" + gitea_context "code.gitea.io/gitea/services/context" ) // HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present func HookProcReceive(ctx *gitea_context.PrivateContext) { opts := web.GetForm(ctx).(*private.HookOptions) - if !git.SupportProcReceive { + if !git.DefaultFeatures.SupportProcReceive { ctx.Status(http.StatusNotFound) return } diff --git a/routers/private/hook_verification.go b/routers/private/hook_verification.go index 8604789529a..764c976fa91 100644 --- a/routers/private/hook_verification.go +++ b/routers/private/hook_verification.go @@ -29,7 +29,8 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env [] }() var command *git.Command - if oldCommitID == git.EmptySHA { + objectFormat, _ := repo.GetObjectFormat() + if oldCommitID == objectFormat.EmptyObjectID().String() { // When creating a new branch, the oldCommitID is empty, by using "newCommitID --not --all": // List commits that are reachable by following the newCommitID, exclude "all" existing heads/tags commits // So, it only lists the new commits received, doesn't list the commits already present in the receiving repository @@ -46,7 +47,7 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env [] _ = stdoutWriter.Close() err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) if err != nil { - log.Error("%v", err) + log.Error("readAndVerifyCommitsFromShaReader failed: %v", err) cancel() } _ = stdoutReader.Close() @@ -65,7 +66,6 @@ func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository line := scanner.Text() err := readAndVerifyCommit(line, repo, env) if err != nil { - log.Error("%v", err) return err } } @@ -82,7 +82,8 @@ func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error { _ = stdoutReader.Close() _ = stdoutWriter.Close() }() - hash := git.MustIDFromString(sha) + + commitID := git.MustIDFromString(sha) return git.NewCommand(repo.Ctx, "cat-file", "commit").AddDynamicArguments(sha). Run(&git.RunOpts{ @@ -91,7 +92,7 @@ func readAndVerifyCommit(sha string, repo *git.Repository, env []string) error { Stdout: stdoutWriter, PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { _ = stdoutWriter.Close() - commit, err := git.CommitFromReader(repo, hash, stdoutReader) + commit, err := git.CommitFromReader(repo, commitID, stdoutReader) if err != nil { return err } diff --git a/routers/private/hook_verification_test.go b/routers/private/hook_verification_test.go index cd0c05ff50a..04445b8eaf3 100644 --- a/routers/private/hook_verification_test.go +++ b/routers/private/hook_verification_test.go @@ -22,14 +22,17 @@ func TestVerifyCommits(t *testing.T) { defer gitRepo.Close() assert.NoError(t, err) + objectFormat, err := gitRepo.GetObjectFormat() + assert.NoError(t, err) + testCases := []struct { base, head string verified bool }{ {"72920278f2f999e3005801e5d5b8ab8139d3641c", "d766f2917716d45be24bfa968b8409544941be32", true}, - {git.EmptySHA, "93eac826f6188f34646cea81bf426aa5ba7d3bfe", true}, // New branch with verified commit + {objectFormat.EmptyObjectID().String(), "93eac826f6188f34646cea81bf426aa5ba7d3bfe", true}, // New branch with verified commit {"9779d17a04f1e2640583d35703c62460b2d86e0a", "72920278f2f999e3005801e5d5b8ab8139d3641c", false}, - {git.EmptySHA, "9ce3f779ae33f31fce17fac3c512047b75d7498b", false}, // New branch with unverified commit + {objectFormat.EmptyObjectID().String(), "9ce3f779ae33f31fce17fac3c512047b75d7498b", false}, // New branch with unverified commit } for _, tc := range testCases { diff --git a/routers/private/internal.go b/routers/private/internal.go index 407edebeede..ede310113ca 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -8,11 +8,11 @@ import ( "net/http" "strings" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" chi_middleware "github.com/go-chi/chi/v5/middleware" diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go index 5e7e82b03c9..e8ee8ba8ac3 100644 --- a/routers/private/internal_repo.go +++ b/routers/private/internal_repo.go @@ -9,10 +9,10 @@ import ( "net/http" repo_model "code.gitea.io/gitea/models/repo" - gitea_context "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" + gitea_context "code.gitea.io/gitea/services/context" ) // This file contains common functions relating to setting the Repository for the internal routes @@ -28,7 +28,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) context.CancelFunc { return nil } - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { log.Error("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err) ctx.JSON(http.StatusInternalServerError, private.Response{ diff --git a/routers/private/key.go b/routers/private/key.go index a13b4c12ae3..5b8f238a83e 100644 --- a/routers/private/key.go +++ b/routers/private/key.go @@ -7,16 +7,16 @@ import ( "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/context" ) // UpdatePublicKeyInRepo update public key and deploy key updates func UpdatePublicKeyInRepo(ctx *context.PrivateContext) { keyID := ctx.ParamsInt64(":id") repoID := ctx.ParamsInt64(":repoid") - if err := asymkey_model.UpdatePublicKeyUpdated(keyID); err != nil { + if err := asymkey_model.UpdatePublicKeyUpdated(ctx, keyID); err != nil { ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), }) @@ -35,7 +35,7 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) { return } deployKey.UpdatedUnix = timeutil.TimeStampNow() - if err = asymkey_model.UpdateDeployKeyCols(deployKey, "updated_unix"); err != nil { + if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil { ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), }) diff --git a/routers/private/mail.go b/routers/private/mail.go index e5e162c8809..cf3abb31c6e 100644 --- a/routers/private/mail.go +++ b/routers/private/mail.go @@ -11,11 +11,11 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" ) @@ -35,7 +35,7 @@ func SendEmail(ctx *context.PrivateContext) { defer rd.Close() if err := json.NewDecoder(rd).Decode(&mail); err != nil { - log.Error("%v", err) + log.Error("JSON Decode failed: %v", err) ctx.JSON(http.StatusInternalServerError, private.Response{ Err: err.Error(), }) diff --git a/routers/private/main_test.go b/routers/private/main_test.go index 700af6ec8d4..a6bec72b41d 100644 --- a/routers/private/main_test.go +++ b/routers/private/main_test.go @@ -4,14 +4,11 @@ package private import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/private/manager.go b/routers/private/manager.go index 397e6fac7bf..a6aa03e4ec9 100644 --- a/routers/private/manager.go +++ b/routers/private/manager.go @@ -8,7 +8,6 @@ import ( "net/http" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful/releasereopen" "code.gitea.io/gitea/modules/log" @@ -17,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" ) // ReloadTemplates reloads all the templates diff --git a/routers/private/manager_process.go b/routers/private/manager_process.go index 68e4a21805b..9a0298a37c6 100644 --- a/routers/private/manager_process.go +++ b/routers/private/manager_process.go @@ -11,10 +11,10 @@ import ( "runtime" "time" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" process_module "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/services/context" ) // Processes prints out the processes diff --git a/routers/private/manager_unix.go b/routers/private/manager_unix.go index 09ced33b8d7..0c63ebc918f 100644 --- a/routers/private/manager_unix.go +++ b/routers/private/manager_unix.go @@ -8,8 +8,8 @@ package private import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/services/context" ) // Restart causes the server to perform a graceful restart diff --git a/routers/private/manager_windows.go b/routers/private/manager_windows.go index bd3c3c30d06..f1b9365f528 100644 --- a/routers/private/manager_windows.go +++ b/routers/private/manager_windows.go @@ -8,9 +8,9 @@ package private import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/services/context" ) // Restart is not implemented for Windows based servers as they can't fork diff --git a/routers/private/restore_repo.go b/routers/private/restore_repo.go index 7efc22a3d9b..4e95d3071db 100644 --- a/routers/private/restore_repo.go +++ b/routers/private/restore_repo.go @@ -7,9 +7,9 @@ import ( "io" "net/http" - myCtx "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/private" + myCtx "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/migrations" ) diff --git a/routers/private/serv.go b/routers/private/serv.go index b1efc588007..85368a0aed4 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -14,11 +14,11 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" wiki_service "code.gitea.io/gitea/services/wiki" ) @@ -33,7 +33,7 @@ func ServNoCommand(ctx *context.PrivateContext) { } results := private.KeyAndOwner{} - key, err := asymkey_model.GetPublicKeyByID(keyID) + key, err := asymkey_model.GetPublicKeyByID(ctx, keyID) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { ctx.JSON(http.StatusUnauthorized, private.Response{ @@ -132,7 +132,7 @@ func ServCommand(ctx *context.PrivateContext) { // Now get the Repository and set the results section repoExist := true - repo, err := repo_model.GetRepositoryByName(owner.ID, results.RepoName) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName) if err != nil { if repo_model.IsErrRepoNotExist(err) { repoExist = false @@ -184,7 +184,7 @@ func ServCommand(ctx *context.PrivateContext) { } // Get the Public Key represented by the keyID - key, err := asymkey_model.GetPublicKeyByID(keyID) + key, err := asymkey_model.GetPublicKeyByID(ctx, keyID) if err != nil { if asymkey_model.IsErrKeyNotExist(err) { ctx.JSON(http.StatusNotFound, private.Response{ @@ -297,7 +297,7 @@ func ServCommand(ctx *context.PrivateContext) { } } else { // Because of the special ref "refs/for" we will need to delay write permission check - if git.SupportProcReceive && unitType == unit.TypeCode { + if git.DefaultFeatures.SupportProcReceive && unitType == unit.TypeCode { mode = perm.AccessModeRead } diff --git a/routers/private/ssh_log.go b/routers/private/ssh_log.go index eacfa18f058..5bec632ead9 100644 --- a/routers/private/ssh_log.go +++ b/routers/private/ssh_log.go @@ -6,11 +6,11 @@ package private import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" ) // SSHLog hook to response ssh log diff --git a/routers/utils/utils.go b/routers/utils/utils.go index d6856fceacd..3035073d5c5 100644 --- a/routers/utils/utils.go +++ b/routers/utils/utils.go @@ -5,34 +5,10 @@ package utils import ( "html" - "net/url" "strings" - - "code.gitea.io/gitea/modules/setting" ) -// RemoveUsernameParameterSuffix returns the username parameter without the (fullname) suffix - leaving just the username -func RemoveUsernameParameterSuffix(name string) string { - if index := strings.Index(name, " ("); index >= 0 { - name = name[:index] - } - return name -} - // SanitizeFlashErrorString will sanitize a flash error string func SanitizeFlashErrorString(x string) string { return strings.ReplaceAll(html.EscapeString(x), "\n", "
") } - -// IsExternalURL checks if rawURL points to an external URL like http://example.com -func IsExternalURL(rawURL string) bool { - parsed, err := url.Parse(rawURL) - if err != nil { - return true - } - appURL, _ := url.Parse(setting.AppURL) - if len(parsed.Host) != 0 && strings.Replace(parsed.Host, "www.", "", 1) != strings.Replace(appURL.Host, "www.", "", 1) { - return true - } - return false -} diff --git a/routers/utils/utils_test.go b/routers/utils/utils_test.go index 6d19214c88e..6e7f3c33cda 100644 --- a/routers/utils/utils_test.go +++ b/routers/utils/utils_test.go @@ -5,53 +5,8 @@ package utils import ( "testing" - - "code.gitea.io/gitea/modules/setting" - - "github.com/stretchr/testify/assert" ) -func TestRemoveUsernameParameterSuffix(t *testing.T) { - assert.Equal(t, "foobar", RemoveUsernameParameterSuffix("foobar (Foo Bar)")) - assert.Equal(t, "foobar", RemoveUsernameParameterSuffix("foobar")) - assert.Equal(t, "", RemoveUsernameParameterSuffix("")) -} - -func TestIsExternalURL(t *testing.T) { - setting.AppURL = "https://try.gitea.io/" - type test struct { - Expected bool - RawURL string - } - newTest := func(expected bool, rawURL string) test { - return test{Expected: expected, RawURL: rawURL} - } - for _, test := range []test{ - newTest(false, - "https://try.gitea.io"), - newTest(true, - "https://example.com/"), - newTest(true, - "//example.com"), - newTest(true, - "http://example.com"), - newTest(false, - "a/"), - newTest(false, - "https://try.gitea.io/test?param=false"), - newTest(false, - "test?param=false"), - newTest(false, - "//try.gitea.io/test?param=false"), - newTest(false, - "/hey/hey/hey#3244"), - newTest(true, - "://missing protocol scheme"), - } { - assert.Equal(t, test.Expected, IsExternalURL(test.RawURL)) - } -} - func TestSanitizeFlashErrorString(t *testing.T) { tests := []struct { name string diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index e34124049e7..e6585d8833b 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -12,26 +12,30 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/updatechecker" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/cron" "code.gitea.io/gitea/services/forms" + release_service "code.gitea.io/gitea/services/release" repo_service "code.gitea.io/gitea/services/repository" ) const ( - tplDashboard base.TplName = "admin/dashboard" - tplCron base.TplName = "admin/cron" - tplQueue base.TplName = "admin/queue" - tplStacktrace base.TplName = "admin/stacktrace" - tplQueueManage base.TplName = "admin/queue_manage" - tplStats base.TplName = "admin/stats" + tplDashboard base.TplName = "admin/dashboard" + tplSystemStatus base.TplName = "admin/system_status" + tplSelfCheck base.TplName = "admin/self_check" + tplCron base.TplName = "admin/cron" + tplQueue base.TplName = "admin/queue" + tplStacktrace base.TplName = "admin/stacktrace" + tplQueueManage base.TplName = "admin/queue_manage" + tplStats base.TplName = "admin/stats" ) var sysStatus struct { @@ -69,7 +73,7 @@ var sysStatus struct { // Garbage collector statistics. NextGC string // next run in HeapAlloc time (bytes) - LastGC string // last run in absolute time (ns) + LastGCTime string // last run time PauseTotalNs string PauseNs string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256] NumGC uint32 @@ -107,17 +111,17 @@ func updateSystemStatus() { sysStatus.OtherSys = base.FileSize(int64(m.OtherSys)) sysStatus.NextGC = base.FileSize(int64(m.NextGC)) - sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000) + sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339) sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000) sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000) sysStatus.NumGC = m.NumGC } -func prepareDeprecatedWarningsAlert(ctx *context.Context) { - if len(setting.DeprecatedWarnings) > 0 { - content := setting.DeprecatedWarnings[0] - if len(setting.DeprecatedWarnings) > 1 { - content += fmt.Sprintf(" (and %d more)", len(setting.DeprecatedWarnings)-1) +func prepareStartupProblemsAlert(ctx *context.Context) { + if len(setting.StartupProblems) > 0 { + content := setting.StartupProblems[0] + if len(setting.StartupProblems) > 1 { + content += fmt.Sprintf(" (and %d more)", len(setting.StartupProblems)-1) } ctx.Flash.Error(content, true) } @@ -127,16 +131,21 @@ func prepareDeprecatedWarningsAlert(ctx *context.Context) { func Dashboard(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate() - ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion() - // FIXME: update periodically + ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate(ctx) + ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx) updateSystemStatus() ctx.Data["SysStatus"] = sysStatus ctx.Data["SSH"] = setting.SSH - prepareDeprecatedWarningsAlert(ctx) + prepareStartupProblemsAlert(ctx) ctx.HTML(http.StatusOK, tplDashboard) } +func SystemStatus(ctx *context.Context) { + updateSystemStatus() + ctx.Data["SysStatus"] = sysStatus + ctx.HTML(http.StatusOK, tplSystemStatus) +} + // DashboardPost run an admin operation func DashboardPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.AdminDashboardForm) @@ -155,6 +164,13 @@ func DashboardPost(ctx *context.Context) { } }() ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_branch.started")) + case "sync_repo_tags": + go func() { + if err := release_service.AddAllRepoTagsToSyncQueue(graceful.GetManager().ShutdownContext()); err != nil { + log.Error("AddAllRepoTagsToSyncQueue: %v: %v", ctx.Doer.ID, err) + } + }() + ctx.Flash.Success(ctx.Tr("admin.dashboard.sync_tag.started")) default: task := cron.GetTask(form.Op) if task != nil { @@ -172,6 +188,41 @@ func DashboardPost(ctx *context.Context) { } } +func SelfCheck(ctx *context.Context) { + ctx.Data["PageIsAdminSelfCheck"] = true + + ctx.Data["StartupProblems"] = setting.StartupProblems + if len(setting.StartupProblems) == 0 && !setting.IsProd { + if time.Now().Unix()%2 == 0 { + ctx.Data["StartupProblems"] = []string{"This is a test warning message in dev mode"} + } + } + + r, err := db.CheckCollationsDefaultEngine() + if err != nil { + ctx.Flash.Error(fmt.Sprintf("CheckCollationsDefaultEngine: %v", err), true) + } + + if r != nil { + ctx.Data["DatabaseType"] = setting.Database.Type + ctx.Data["DatabaseCheckResult"] = r + hasProblem := false + if !r.CollationEquals(r.DatabaseCollation, r.ExpectedCollation) { + ctx.Data["DatabaseCheckCollationMismatch"] = true + hasProblem = true + } + if !r.IsCollationCaseSensitive(r.DatabaseCollation) { + ctx.Data["DatabaseCheckCollationCaseInsensitive"] = true + hasProblem = true + } + ctx.Data["DatabaseCheckInconsistentCollationColumns"] = r.InconsistentCollationColumns + hasProblem = hasProblem || len(r.InconsistentCollationColumns) > 0 + + ctx.Data["DatabaseCheckHasProblems"] = hasProblem + } + ctx.HTML(http.StatusOK, tplSelfCheck) +} + func CronTasks(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.monitor.cron") ctx.Data["PageIsAdminMonitorCron"] = true diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index b26912db482..85833980746 100644 --- a/routers/web/admin/applications.go +++ b/routers/web/admin/applications.go @@ -8,10 +8,11 @@ import ( "net/http" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" ) var ( @@ -33,7 +34,9 @@ func Applications(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.applications") ctx.Data["PageIsAdminApplications"] = true - apps, err := auth.GetOAuth2ApplicationsByUserID(ctx, 0) + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + IsGlobal: true, + }) if err != nil { ctx.ServerError("GetOAuth2ApplicationsByUserID", err) return diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index b743d1b0a55..ba487d10457 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -13,9 +13,9 @@ import ( "strings" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/auth/pam" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -26,6 +26,7 @@ import ( pam_service "code.gitea.io/gitea/services/auth/source/pam" "code.gitea.io/gitea/services/auth/source/smtp" "code.gitea.io/gitea/services/auth/source/sspi" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "xorm.io/xorm/convert" @@ -48,13 +49,12 @@ func Authentications(ctx *context.Context) { ctx.Data["PageIsAdminAuthentications"] = true var err error - ctx.Data["Sources"], err = auth.Sources() + ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{}) if err != nil { ctx.ServerError("auth.Sources", err) return } - ctx.Data["Total"] = auth.CountSources() ctx.HTML(http.StatusOK, tplAuths) } @@ -99,7 +99,7 @@ func NewAuthSource(ctx *context.Context) { ctx.Data["AuthSources"] = authSources ctx.Data["SecurityProtocols"] = securityProtocols ctx.Data["SMTPAuths"] = smtp.Authenticators - oauth2providers := oauth2.GetOAuth2Providers() + oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers ctx.Data["SSPIAutoCreateUsers"] = true @@ -210,16 +210,16 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) { if util.IsEmptyString(form.SSPISeparatorReplacement) { ctx.Data["Err_SSPISeparatorReplacement"] = true - return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.require_error")) + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.require_error")) } if separatorAntiPattern.MatchString(form.SSPISeparatorReplacement) { ctx.Data["Err_SSPISeparatorReplacement"] = true - return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.alpha_dash_dot_error")) + return nil, errors.New(ctx.Locale.TrString("form.SSPISeparatorReplacement") + ctx.Locale.TrString("form.alpha_dash_dot_error")) } if form.SSPIDefaultLanguage != "" && !langCodePattern.MatchString(form.SSPIDefaultLanguage) { ctx.Data["Err_SSPIDefaultLanguage"] = true - return nil, errors.New(ctx.Tr("form.lang_select_error")) + return nil, errors.New(ctx.Locale.TrString("form.lang_select_error")) } return &sspi.Source{ @@ -242,7 +242,7 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.Data["AuthSources"] = authSources ctx.Data["SecurityProtocols"] = securityProtocols ctx.Data["SMTPAuths"] = smtp.Authenticators - oauth2providers := oauth2.GetOAuth2Providers() + oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers ctx.Data["SSPIAutoCreateUsers"] = true @@ -284,7 +284,7 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.RenderWithErr(err.Error(), tplAuthNew, form) return } - existing, err := auth.SourcesByType(auth.SSPI) + existing, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{LoginType: auth.SSPI}) if err != nil || len(existing) > 0 { ctx.Data["Err_Type"] = true ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_of_type_exist"), tplAuthNew, form) @@ -301,7 +301,7 @@ func NewAuthSourcePost(ctx *context.Context) { return } - if err := auth.CreateSource(&auth.Source{ + if err := auth.CreateSource(ctx, &auth.Source{ Type: auth.Type(form.Type), Name: form.Name, IsActive: form.IsActive, @@ -334,10 +334,10 @@ func EditAuthSource(ctx *context.Context) { ctx.Data["SecurityProtocols"] = securityProtocols ctx.Data["SMTPAuths"] = smtp.Authenticators - oauth2providers := oauth2.GetOAuth2Providers() + oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers - source, err := auth.GetSourceByID(ctx.ParamsInt64(":authid")) + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) if err != nil { ctx.ServerError("auth.GetSourceByID", err) return @@ -368,10 +368,10 @@ func EditAuthSourcePost(ctx *context.Context) { ctx.Data["PageIsAdminAuthentications"] = true ctx.Data["SMTPAuths"] = smtp.Authenticators - oauth2providers := oauth2.GetOAuth2Providers() + oauth2providers := oauth2.GetSupportedOAuth2Providers() ctx.Data["OAuth2Providers"] = oauth2providers - source, err := auth.GetSourceByID(ctx.ParamsInt64(":authid")) + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) if err != nil { ctx.ServerError("auth.GetSourceByID", err) return @@ -421,7 +421,7 @@ func EditAuthSourcePost(ctx *context.Context) { source.IsActive = form.IsActive source.IsSyncEnabled = form.IsSyncEnabled source.Cfg = config - if err := auth.UpdateSource(source); err != nil { + if err := auth.UpdateSource(ctx, source); err != nil { if auth.IsErrSourceAlreadyExist(err) { ctx.Data["Err_Name"] = true ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form) @@ -442,13 +442,13 @@ func EditAuthSourcePost(ctx *context.Context) { // DeleteAuthSource response for deleting an auth source func DeleteAuthSource(ctx *context.Context) { - source, err := auth.GetSourceByID(ctx.ParamsInt64(":authid")) + source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid")) if err != nil { ctx.ServerError("auth.GetSourceByID", err) return } - if err = auth_service.DeleteSource(source); err != nil { + if err = auth_service.DeleteSource(ctx, source); err != nil { if auth.IsErrSourceInUse(err) { ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used")) } else { diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index c70a2d1c951..48f80dbbf1c 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -5,7 +5,6 @@ package admin import ( - "fmt" "net/http" "net/url" "strconv" @@ -13,18 +12,22 @@ import ( system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" "gitea.com/go-chi/session" ) -const tplConfig base.TplName = "admin/config" +const ( + tplConfig base.TplName = "admin/config" + tplConfigSettings base.TplName = "admin/config_settings" +) // SendTestMail send test mail to confirm mail service is OK func SendTestMail(ctx *context.Context) { @@ -98,18 +101,9 @@ func shadowPassword(provider, cfgItem string) string { // Config show admin config page func Config(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("admin.config") + ctx.Data["Title"] = ctx.Tr("admin.config_summary") ctx.Data["PageIsAdminConfig"] = true - - systemSettings, err := system_model.GetAllSettings(ctx) - if err != nil { - ctx.ServerError("system_model.GetAllSettings", err) - return - } - - // All editable settings from UI - ctx.Data["SystemSettings"] = systemSettings - ctx.PageData["adminConfigPage"] = true + ctx.Data["PageIsAdminConfigSummary"] = true ctx.Data["CustomConf"] = setting.CustomConf ctx.Data["AppUrl"] = setting.AppURL @@ -170,59 +164,75 @@ func Config(ctx *context.Context) { ctx.Data["LogSQL"] = setting.Database.LogSQL ctx.Data["Loggers"] = log.GetManager().DumpLoggers() - - prepareDeprecatedWarningsAlert(ctx) + config.GetDynGetter().InvalidateCache() + prepareStartupProblemsAlert(ctx) ctx.HTML(http.StatusOK, tplConfig) } +func ConfigSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.config_settings") + ctx.Data["PageIsAdminConfig"] = true + ctx.Data["PageIsAdminConfigSettings"] = true + ctx.Data["DefaultOpenWithEditorAppsString"] = setting.DefaultOpenWithEditorApps().ToTextareaString() + ctx.HTML(http.StatusOK, tplConfigSettings) +} + func ChangeConfig(ctx *context.Context) { key := strings.TrimSpace(ctx.FormString("key")) - if key == "" { - ctx.JSONRedirect(ctx.Req.URL.String()) - return - } value := ctx.FormString("value") - version := ctx.FormInt("version") + cfg := setting.Config() - if check, ok := changeConfigChecks[key]; ok { - if err := check(ctx, value); err != nil { - log.Warn("refused to set setting: %v", err) - ctx.JSON(http.StatusOK, map[string]string{ - "err": ctx.Tr("admin.config.set_setting_failed", key), + marshalBool := func(v string) (string, error) { + if b, _ := strconv.ParseBool(v); b { + return "true", nil + } + return "false", nil + } + marshalOpenWithApps := func(value string) (string, error) { + lines := strings.Split(value, "\n") + var openWithEditorApps setting.OpenWithEditorAppsType + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + displayName, openURL, ok := strings.Cut(line, "=") + displayName, openURL = strings.TrimSpace(displayName), strings.TrimSpace(openURL) + if !ok || displayName == "" || openURL == "" { + continue + } + openWithEditorApps = append(openWithEditorApps, setting.OpenWithEditorApp{ + DisplayName: strings.TrimSpace(displayName), + OpenURL: strings.TrimSpace(openURL), }) - return } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil } - - if err := system_model.SetSetting(ctx, &system_model.Setting{ - SettingKey: key, - SettingValue: value, - Version: version, - }); err != nil { - log.Error("set setting failed: %v", err) - ctx.JSON(http.StatusOK, map[string]string{ - "err": ctx.Tr("admin.config.set_setting_failed", key), - }) + marshallers := map[string]func(string) (string, error){ + cfg.Picture.DisableGravatar.DynKey(): marshalBool, + cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, + cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, + } + marshaller, hasMarshaller := marshallers[key] + if !hasMarshaller { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + marshaledValue, err := marshaller(value) + if err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) + return + } + if err = system_model.SetSettings(ctx, map[string]string{key: marshaledValue}); err != nil { + ctx.JSONError(ctx.Tr("admin.config.set_setting_failed", key)) return } - ctx.JSON(http.StatusOK, map[string]any{ - "version": version + 1, - }) -} - -var changeConfigChecks = map[string]func(ctx *context.Context, newValue string) error{ - system_model.KeyPictureDisableGravatar: func(_ *context.Context, newValue string) error { - if v, _ := strconv.ParseBool(newValue); setting.OfflineMode && !v { - return fmt.Errorf("%q should be true when OFFLINE_MODE is true", system_model.KeyPictureDisableGravatar) - } - return nil - }, - system_model.KeyPictureEnableFederatedAvatar: func(_ *context.Context, newValue string) error { - if v, _ := strconv.ParseBool(newValue); setting.OfflineMode && v { - return fmt.Errorf("%q cannot be false when OFFLINE_MODE is true", system_model.KeyPictureEnableFederatedAvatar) - } - return nil - }, + config.GetDynGetter().InvalidateCache() + ctx.JSONOK() } diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go index 5637894e6de..020554a35a4 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -9,8 +9,8 @@ import ( "runtime/pprof" "time" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/services/context" ) func MonitorDiagnosis(ctx *context.Context) { @@ -58,4 +58,11 @@ func MonitorDiagnosis(ctx *context.Context) { return } _ = pprof.Lookup("goroutine").WriteTo(f, 1) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "heap.dat", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + _ = pprof.Lookup("heap").WriteTo(f, 0) } diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go index 59f80035d89..2cf4035c6a8 100644 --- a/routers/web/admin/emails.go +++ b/routers/web/admin/emails.go @@ -11,10 +11,10 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -68,10 +68,10 @@ func Emails(ctx *context.Context) { opts.Keyword = ctx.FormTrim("q") opts.SortType = orderBy if len(ctx.FormString("is_activated")) != 0 { - opts.IsActivated = util.OptionalBoolOf(ctx.FormBool("activated")) + opts.IsActivated = optional.Some(ctx.FormBool("activated")) } if len(ctx.FormString("is_primary")) != 0 { - opts.IsPrimary = util.OptionalBoolOf(ctx.FormBool("primary")) + opts.IsPrimary = optional.Some(ctx.FormBool("primary")) } if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index cd8cc29cdfb..8d59fbb858e 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -8,9 +8,9 @@ import ( "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -35,7 +35,7 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { sys["Title"] = ctx.Tr("admin.systemhooks") sys["Description"] = ctx.Tr("admin.systemhooks.desc") - sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, util.OptionalBoolNone) + sys["Webhooks"], err = webhook.GetSystemWebhooks(ctx, optional.None[bool]()) sys["BaseLink"] = setting.AppSubURL + "/admin/hooks" sys["BaseLinkNew"] = setting.AppSubURL + "/admin/system-hooks" if err != nil { diff --git a/routers/web/admin/main_test.go b/routers/web/admin/main_test.go index ea79830fa13..e1294ddbb40 100644 --- a/routers/web/admin/main_test.go +++ b/routers/web/admin/main_test.go @@ -4,14 +4,11 @@ package admin import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go index 9e4588dd757..36303cbc06e 100644 --- a/routers/web/admin/notice.go +++ b/routers/web/admin/notice.go @@ -8,11 +8,12 @@ import ( "net/http" "strconv" + "code.gitea.io/gitea/models/db" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) const ( @@ -24,13 +25,13 @@ func Notices(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.notices") ctx.Data["PageIsAdminNotices"] = true - total := system_model.CountNotices() + total := system_model.CountNotices(ctx) page := ctx.FormInt("page") if page <= 1 { page = 1 } - notices, err := system_model.Notices(page, setting.UI.Admin.NoticePagingNum) + notices, err := system_model.Notices(ctx, page, setting.UI.Admin.NoticePagingNum) if err != nil { ctx.ServerError("Notices", err) return @@ -55,7 +56,7 @@ func DeleteNotices(ctx *context.Context) { } } - if err := system_model.DeleteNoticesByIDs(ids); err != nil { + if err := db.DeleteByIDs[system_model.Notice](ctx, ids...); err != nil { ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error()) ctx.Status(http.StatusInternalServerError) } else { @@ -66,7 +67,7 @@ func DeleteNotices(ctx *context.Context) { // EmptyNotices delete all the notices func EmptyNotices(ctx *context.Context) { - if err := system_model.DeleteNotices(0, 0); err != nil { + if err := system_model.DeleteNotices(ctx, 0, 0); err != nil { ctx.ServerError("DeleteNotices", err) return } diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index ab44f8048bf..c5454db71e3 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -8,10 +8,10 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" ) const ( @@ -24,7 +24,7 @@ func Organizations(ctx *context.Context) { ctx.Data["PageIsAdminOrganizations"] = true if ctx.FormString("sort") == "" { - ctx.SetFormString("sort", explore.UserSearchDefaultAdminSort) + ctx.SetFormString("sort", UserSearchDefaultAdminSort) } explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go index 8d4c29813ef..39f064a1be6 100644 --- a/routers/web/admin/packages.go +++ b/routers/web/admin/packages.go @@ -11,9 +11,9 @@ import ( "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" packages_service "code.gitea.io/gitea/services/packages" packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" ) @@ -36,7 +36,7 @@ func Packages(ctx *context.Context) { Type: packages_model.Type(packageType), Name: packages_model.SearchValue{Value: query}, Sort: sort, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Paginator: &db.ListOptions{ PageSize: setting.UI.PackagesPagingNum, Page: page, @@ -93,7 +93,7 @@ func DeletePackageVersion(ctx *context.Context) { return } - if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil { ctx.ServerError("RemovePackageVersion", err) return } @@ -108,6 +108,6 @@ func CleanupExpiredData(ctx *context.Context) { return } - ctx.Flash.Success(ctx.Tr("packages.cleanup.success")) + ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success")) ctx.Redirect(setting.AppSubURL + "/admin/packages") } diff --git a/routers/web/admin/queue.go b/routers/web/admin/queue.go index 18a8d7d3e68..d8c50730b14 100644 --- a/routers/web/admin/queue.go +++ b/routers/web/admin/queue.go @@ -7,9 +7,9 @@ import ( "net/http" "strconv" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) func Queues(ctx *context.Context) { diff --git a/routers/web/admin/repos.go b/routers/web/admin/repos.go index 45c280ef731..0815879bb3e 100644 --- a/routers/web/admin/repos.go +++ b/routers/web/admin/repos.go @@ -4,6 +4,7 @@ package admin import ( + "fmt" "net/http" "net/url" "strings" @@ -12,11 +13,11 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) @@ -84,7 +85,7 @@ func UnadoptedRepos(ctx *context.Context) { if !doSearch { pager := context.NewPagination(0, opts.PageSize, opts.Page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "search", "search") + pager.AddParamString("search", fmt.Sprint(doSearch)) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplUnadoptedRepos) return @@ -98,7 +99,7 @@ func UnadoptedRepos(ctx *context.Context) { ctx.Data["Dirs"] = repoNames pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "search", "search") + pager.AddParamString("search", fmt.Sprint(doSearch)) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplUnadoptedRepos) } diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go index eaa268b4f14..d73290a8dba 100644 --- a/routers/web/admin/runners.go +++ b/routers/web/admin/runners.go @@ -4,8 +4,8 @@ package admin import ( - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) func RedirectToDefaultSetting(ctx *context.Context) { diff --git a/routers/web/admin/stacktrace.go b/routers/web/admin/stacktrace.go index b603fb59a26..d6def94bb49 100644 --- a/routers/web/admin/stacktrace.go +++ b/routers/web/admin/stacktrace.go @@ -7,9 +7,9 @@ import ( "net/http" "runtime" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) // Stacktrace show admin monitor goroutines page diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 5562cc390c1..ea9d6f4c9cf 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -5,6 +5,7 @@ package admin import ( + "errors" "net/http" "net/url" "strconv" @@ -15,17 +16,17 @@ import ( "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" - system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/explore" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" user_service "code.gitea.io/gitea/services/user" @@ -38,6 +39,9 @@ const ( tplUserEdit base.TplName = "admin/user/edit" ) +// UserSearchDefaultAdminSort is the default sort type for admin view +const UserSearchDefaultAdminSort = "alphabetically" + // Users show all the users func Users(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users") @@ -57,7 +61,7 @@ func Users(ctx *context.Context) { sortType := ctx.FormString("sort") if sortType == "" { - sortType = explore.UserSearchDefaultAdminSort + sortType = UserSearchDefaultAdminSort ctx.SetFormString("sort", sortType) } ctx.PageData["adminUserListSearchForm"] = map[string]any{ @@ -91,7 +95,9 @@ func NewUser(ctx *context.Context) { ctx.Data["login_type"] = "0-0" - sources, err := auth.Sources() + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) if err != nil { ctx.ServerError("auth.Sources", err) return @@ -110,7 +116,9 @@ func NewUserPost(ctx *context.Context) { ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - sources, err := auth.Sources() + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) if err != nil { ctx.ServerError("auth.Sources", err) return @@ -132,7 +140,7 @@ func NewUserPost(ctx *context.Context) { } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), Visibility: &form.Visibility, } @@ -156,11 +164,10 @@ func NewUserPost(ctx *context.Context) { ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserNew, &form) return } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { + if err := password.IsPwned(ctx, form.Password); err != nil { ctx.Data["Err_Password"] = true errMsg := ctx.Tr("auth.password_pwned") - if err != nil { + if password.IsErrIsPwnedRequest(err) { log.Error(err.Error()) errMsg = ctx.Tr("auth.password_pwned_err") } @@ -170,7 +177,7 @@ func NewUserPost(ctx *context.Context) { u.MustChangePassword = form.MustChangePassword } - if err := user_model.CreateUser(ctx, u, overwriteDefault); err != nil { + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { switch { case user_model.IsErrUserAlreadyExist(err): ctx.Data["Err_UserName"] = true @@ -178,10 +185,7 @@ func NewUserPost(ctx *context.Context) { case user_model.IsErrEmailAlreadyUsed(err): ctx.Data["Err_Email"] = true ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserNew, &form) - case user_model.IsErrEmailCharIsNotSupported(err): - ctx.Data["Err_Email"] = true - ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) - case user_model.IsErrEmailInvalid(err): + case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): ctx.Data["Err_Email"] = true ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserNew, &form) case db.IsErrNameReserved(err): @@ -198,6 +202,11 @@ func NewUserPost(ctx *context.Context) { } return } + + if !user_model.IsEmailDomainAllowed(u.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email)) + } + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. @@ -222,7 +231,7 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { ctx.Data["User"] = u if u.LoginSource > 0 { - ctx.Data["LoginSource"], err = auth.GetSourceByID(u.LoginSource) + ctx.Data["LoginSource"], err = auth.GetSourceByID(ctx, u.LoginSource) if err != nil { ctx.ServerError("auth.GetSourceByID", err) return nil @@ -231,7 +240,7 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { ctx.Data["LoginSource"] = &auth.Source{} } - sources, err := auth.Sources() + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{}) if err != nil { ctx.ServerError("auth.Sources", err) return nil @@ -243,7 +252,7 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { ctx.ServerError("auth.HasTwoFactorByUID", err) return nil } - hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(u.ID) + hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) if err != nil { ctx.ServerError("auth.HasWebAuthnRegistrationsByUID", err) return nil @@ -266,13 +275,11 @@ func ViewUser(ctx *context.Context) { } repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ - ListOptions: db.ListOptions{ - ListAll: true, - }, + ListOptions: db.ListOptionsAll, OwnerID: u.ID, OrderBy: db.SearchOrderByAlphabetically, Private: true, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -290,10 +297,8 @@ func ViewUser(ctx *context.Context) { ctx.Data["Emails"] = emails ctx.Data["EmailsTotal"] = len(emails) - orgs, err := org_model.FindOrgs(org_model.FindOrgOptions{ - ListOptions: db.ListOptions{ - ListAll: true, - }, + orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ + ListOptions: db.ListOptionsAll, UserID: u.ID, IncludePrivate: true, }) @@ -308,17 +313,18 @@ func ViewUser(ctx *context.Context) { ctx.HTML(http.StatusOK, tplUserView) } -// EditUser show editing user page -func EditUser(ctx *context.Context) { +func editUserCommon(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") ctx.Data["PageIsAdminUsers"] = true ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - ctx.Data["DisableGravatar"] = system_model.GetSettingWithCacheBool(ctx, system_model.KeyPictureDisableGravatar, - setting.GetDefaultDisableGravatar(), - ) + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) +} +// EditUser show editing user page +func EditUser(ctx *context.Context) { + editUserCommon(ctx) prepareUserInfo(ctx) if ctx.Written() { return @@ -329,85 +335,125 @@ func EditUser(ctx *context.Context) { // EditUserPost response for editing user func EditUserPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.AdminEditUserForm) - ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") - ctx.Data["PageIsAdminUsers"] = true - ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations - ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - ctx.Data["DisableGravatar"] = system_model.GetSettingWithCacheBool(ctx, system_model.KeyPictureDisableGravatar, - setting.GetDefaultDisableGravatar()) - + editUserCommon(ctx) u := prepareUserInfo(ctx) if ctx.Written() { return } + form := web.GetForm(ctx).(*forms.AdminEditUserForm) if ctx.HasError() { ctx.HTML(http.StatusOK, tplUserEdit) return } + if form.UserName != "" { + if err := user_service.RenameUser(ctx, u, form.UserName); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_change_not_local_user"), tplUserEdit, &form) + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplUserEdit, &form) + case db.IsErrNameReserved(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", form.UserName), tplUserEdit, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", form.UserName), tplUserEdit, &form) + case db.IsErrNameCharsNotAllowed(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", form.UserName), tplUserEdit, &form) + default: + ctx.ServerError("RenameUser", err) + } + return + } + } + + authOpts := &user_service.UpdateAuthOptions{ + Password: optional.FromNonDefault(form.Password), + LoginName: optional.Some(form.LoginName), + } + + // skip self Prohibit Login + if ctx.Doer.ID == u.ID { + authOpts.ProhibitLogin = optional.Some(false) + } else { + authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin) + } + fields := strings.Split(form.LoginType, "-") if len(fields) == 2 { - loginType, _ := strconv.ParseInt(fields[0], 10, 0) authSource, _ := strconv.ParseInt(fields[1], 10, 64) - if u.LoginSource != authSource { - u.LoginSource = authSource - u.LoginType = auth.Type(loginType) - } + authOpts.LoginSource = optional.Some(authSource) } - if len(form.Password) > 0 && (u.IsLocal() || u.IsOAuth2()) { - var err error - if len(form.Password) < setting.MinPasswordLength { + if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): ctx.Data["Err_Password"] = true ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) - return - } - if !password.IsComplexEnough(form.Password) { + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) - return - } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { + case errors.Is(err, password.ErrIsPwned): ctx.Data["Err_Password"] = true - errMsg := ctx.Tr("auth.password_pwned") - if err != nil { - log.Error(err.Error()) - errMsg = ctx.Tr("auth.password_pwned_err") - } - ctx.RenderWithErr(errMsg, tplUserEdit, &form) - return - } - - if err := user_model.ValidateEmail(form.Email); err != nil { - ctx.Data["Err_Email"] = true - ctx.RenderWithErr(ctx.Tr("form.email_error"), tplUserEdit, &form) - return + ctx.RenderWithErr(ctx.Tr("auth.password_pwned"), tplUserEdit, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + default: + ctx.ServerError("UpdateUser", err) } + return + } - if u.Salt, err = user_model.GetUserSalt(); err != nil { - ctx.ServerError("UpdateUser", err) + if form.Email != "" { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserEdit, &form) + default: + ctx.ServerError("AddOrSetPrimaryEmailAddress", err) + } return } - if err = u.SetPassword(form.Password); err != nil { - ctx.ServerError("SetPassword", err) - return + if !user_model.IsEmailDomainAllowed(form.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", form.Email)) } } - if len(form.UserName) != 0 && u.Name != form.UserName { - if err := user_setting.HandleUsernameChange(ctx, u, form.UserName); err != nil { - if ctx.Written() { - return - } - ctx.RenderWithErr(ctx.Flash.ErrorMsg, tplUserEdit, &form) - return + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + IsActive: optional.Some(form.Active), + IsAdmin: optional.Some(form.Admin), + AllowGitHook: optional.Some(form.AllowGitHook), + AllowImportLocal: optional.Some(form.AllowImportLocal), + MaxRepoCreation: optional.Some(form.MaxRepoCreation), + AllowCreateOrganization: optional.Some(form.AllowCreateOrganization), + IsRestricted: optional.Some(form.Restricted), + Visibility: optional.Some(form.Visibility), + Language: optional.Some(form.Language), + } + + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if models.IsErrDeleteLastAdminUser(err) { + ctx.RenderWithErr(ctx.Tr("auth.last_admin"), tplUserEdit, &form) + } else { + ctx.ServerError("UpdateUser", err) } - u.Name = form.UserName - u.LowerName = strings.ToLower(form.UserName) + return } + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) if form.Reset2FA { tf, err := auth.GetTwoFactorByUID(ctx, u.ID) @@ -421,57 +467,18 @@ func EditUserPost(ctx *context.Context) { } } - wn, err := auth.GetWebAuthnCredentialsByUID(u.ID) + wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) if err != nil { ctx.ServerError("auth.GetTwoFactorByUID", err) return } for _, cred := range wn { - if _, err := auth.DeleteCredential(cred.ID, u.ID); err != nil { + if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil { ctx.ServerError("auth.DeleteCredential", err) return } } - - } - - u.LoginName = form.LoginName - u.FullName = form.FullName - emailChanged := !strings.EqualFold(u.Email, form.Email) - u.Email = form.Email - u.Website = form.Website - u.Location = form.Location - u.MaxRepoCreation = form.MaxRepoCreation - u.IsActive = form.Active - u.IsAdmin = form.Admin - u.IsRestricted = form.Restricted - u.AllowGitHook = form.AllowGitHook - u.AllowImportLocal = form.AllowImportLocal - u.AllowCreateOrganization = form.AllowCreateOrganization - - u.Visibility = form.Visibility - - // skip self Prohibit Login - if ctx.Doer.ID == u.ID { - u.ProhibitLogin = false - } else { - u.ProhibitLogin = form.ProhibitLogin - } - - if err := user_model.UpdateUser(ctx, u, emailChanged); err != nil { - if user_model.IsErrEmailAlreadyUsed(err) { - ctx.Data["Err_Email"] = true - ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplUserEdit, &form) - } else if user_model.IsErrEmailCharIsNotSupported(err) || - user_model.IsErrEmailInvalid(err) { - ctx.Data["Err_Email"] = true - ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplUserEdit, &form) - } else { - ctx.ServerError("UpdateUser", err) - } - return } - log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) @@ -502,7 +509,10 @@ func DeleteUser(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) case models.IsErrUserOwnPackages(err): ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages")) - ctx.Redirect(setting.AppSubURL + "/admin/users/" + ctx.Params(":userid")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) default: ctx.ServerError("DeleteUser", err) } @@ -538,7 +548,7 @@ func DeleteAvatar(ctx *context.Context) { return } - if err := user_service.DeleteAvatar(u); err != nil { + if err := user_service.DeleteAvatar(ctx, u); err != nil { ctx.Flash.Error(err.Error()) } diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go index 560ee70ea04..f6f92378581 100644 --- a/routers/web/admin/users_test.go +++ b/routers/web/admin/users_test.go @@ -8,10 +8,10 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" diff --git a/routers/web/auth.go b/routers/web/auth.go deleted file mode 100644 index 1ca860ecc8d..00000000000 --- a/routers/web/auth.go +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build !windows - -package web - -import auth_service "code.gitea.io/gitea/services/auth" - -func specialAdd(group *auth_service.Group) {} diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index 31ede82f010..f93177bf96b 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -10,9 +10,9 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" ) @@ -26,8 +26,7 @@ var ( func TwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } @@ -75,7 +74,7 @@ func TwoFactorPost(ctx *context.Context) { } if ctx.Session.Get("linkAccount") != nil { - err = externalaccount.LinkAccountFromStore(ctx.Session, u) + err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) if err != nil { ctx.ServerError("UserSignIn", err) return @@ -99,8 +98,7 @@ func TwoFactorPost(ctx *context.Context) { func TwoFactorScratch(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa_scratch") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index b7a73e43796..8b5cd986b81 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -7,6 +7,7 @@ package auth import ( "errors" "fmt" + "html/template" "net/http" "strings" @@ -15,69 +16,76 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" - "code.gitea.io/gitea/routers/utils" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" "github.com/markbates/goth" ) const ( - // tplSignIn template for sign in page - tplSignIn base.TplName = "user/auth/signin" - // tplSignUp template path for sign up page - tplSignUp base.TplName = "user/auth/signup" - // TplActivate template path for activate user - TplActivate base.TplName = "user/auth/activate" + tplSignIn base.TplName = "user/auth/signin" // for sign in page + tplSignUp base.TplName = "user/auth/signup" // for sign up page + TplActivate base.TplName = "user/auth/activate" // for activate user + TplActivatePrompt base.TplName = "user/auth/activate_prompt" // for showing a message for user activation ) -// AutoSignIn reads cookie and try to auto-login. -func AutoSignIn(ctx *context.Context) (bool, error) { - if !db.HasEngine { - return false, nil - } - - uname := ctx.GetSiteCookie(setting.CookieUserName) - if len(uname) == 0 { - return false, nil - } - +// autoSignIn reads cookie and try to auto-login. +func autoSignIn(ctx *context.Context) (bool, error) { isSucceed := false defer func() { if !isSucceed { - log.Trace("auto-login cookie cleared: %s", uname) - ctx.DeleteSiteCookie(setting.CookieUserName) ctx.DeleteSiteCookie(setting.CookieRememberName) } }() - u, err := user_model.GetUserByName(ctx, uname) + if err := auth.DeleteExpiredAuthTokens(ctx); err != nil { + log.Error("Failed to delete expired auth tokens: %v", err) + } + + t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName)) if err != nil { - if !user_model.IsErrUserNotExist(err) { - return false, fmt.Errorf("GetUserByName: %w", err) + switch err { + case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired: + return false, nil } + return false, err + } + if t == nil { return false, nil } - if val, ok := ctx.GetSuperSecureCookie( - base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { + u, err := user_model.GetUserByID(ctx, t.UserID) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + return false, fmt.Errorf("GetUserByID: %w", err) + } return false, nil } isSucceed = true + nt, token, err := auth_service.RegenerateAuthToken(ctx, t) + if err != nil { + return false, err + } + + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) + if err := updateSession(ctx, nil, map[string]any{ // Set session IDs "uid": u.ID, @@ -97,9 +105,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) { func resetLocale(ctx *context.Context, u *user_model.User) error { // Language setting of the user overwrites the one previously set // If the user does not have a locale set, we save the current one. - if len(u.Language) == 0 { - u.Language = ctx.Locale.Language() - if err := user_model.UpdateUserCols(ctx, u, "language"); err != nil { + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { return err } } @@ -113,24 +123,37 @@ func resetLocale(ctx *context.Context, u *user_model.User) error { return nil } -func checkAutoLogin(ctx *context.Context) bool { - // Check auto-login - isSucceed, err := AutoSignIn(ctx) +func RedirectAfterLogin(ctx *context.Context) { + redirectTo := ctx.FormString("redirect_to") + if redirectTo == "" { + redirectTo = ctx.GetSiteCookie("redirect_to") + } + middleware.DeleteRedirectToCookie(ctx.Resp) + nextRedirectTo := setting.AppSubURL + string(setting.LandingPageURL) + if setting.LandingPageURL == setting.LandingPageLogin { + nextRedirectTo = setting.AppSubURL + "/" // do not cycle-redirect to the login page + } + ctx.RedirectToCurrentSite(redirectTo, nextRedirectTo) +} + +func CheckAutoLogin(ctx *context.Context) bool { + isSucceed, err := autoSignIn(ctx) // try to auto-login if err != nil { - ctx.ServerError("AutoSignIn", err) + if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) { + ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true) + return false + } + ctx.ServerError("autoSignIn", err) return true } redirectTo := ctx.FormString("redirect_to") if len(redirectTo) > 0 { middleware.SetRedirectToCookie(ctx.Resp, redirectTo) - } else { - redirectTo = ctx.GetSiteCookie("redirect_to") } if isSucceed { - middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo, setting.AppSubURL+string(setting.LandingPageURL)) + RedirectAfterLogin(ctx) return true } @@ -141,23 +164,26 @@ func checkAutoLogin(ctx *context.Context) bool { func SignIn(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_in") - // Check auto-login - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } - orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() + if ctx.IsSigned { + RedirectAfterLogin(ctx) + return + } + + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) if err != nil { ctx.ServerError("UserSignIn", err) return } - ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names ctx.Data["OAuth2Providers"] = oauth2Providers ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" ctx.Data["PageIsSignIn"] = true ctx.Data["PageIsLogin"] = true - ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled() + ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { context.SetCaptchaData(ctx) @@ -170,18 +196,17 @@ func SignIn(ctx *context.Context) { func SignInPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_in") - orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) if err != nil { ctx.ServerError("UserSignIn", err) return } - ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names ctx.Data["OAuth2Providers"] = oauth2Providers ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login" ctx.Data["PageIsSignIn"] = true ctx.Data["PageIsLogin"] = true - ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled() + ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) if ctx.HasError() { ctx.HTML(http.StatusOK, tplSignIn) @@ -243,7 +268,7 @@ func SignInPost(ctx *context.Context) { } // Check if the user has webauthn registration - hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(u.ID) + hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID) if err != nil { ctx.ServerError("UserSignIn", err) return @@ -290,10 +315,13 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { if remember { - days := 86400 * setting.LogInRememberDays - ctx.SetSiteCookie(setting.CookieUserName, u.Name, days) - ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), - setting.CookieRememberName, u.Name, days) + nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("CreateAuthTokenForUserID", err) + return setting.AppSubURL + "/" + } + + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) } if err := updateSession(ctx, []string{ @@ -315,10 +343,12 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe // Language setting of the user overwrites the one previously set // If the user does not have a locale set, we save the current one. - if len(u.Language) == 0 { - u.Language = ctx.Locale.Language() - if err := user_model.UpdateUserCols(ctx, u, "language"); err != nil { - ctx.ServerError("UpdateUserCols Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, u.Language)) + if u.Language == "" { + opts := &user_service.UpdateOptions{ + Language: optional.Some(ctx.Locale.Language()), + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language())) return setting.AppSubURL + "/" } } @@ -333,16 +363,15 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe ctx.Csrf.DeleteCookie(ctx) // Register last login - u.SetLastLogin() - if err := user_model.UpdateUserCols(ctx, u, "last_login_unix"); err != nil { - ctx.ServerError("UpdateUserCols", err) + if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) return setting.AppSubURL + "/" } - if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" && httplib.IsCurrentGiteaSiteURL(redirectTo) { middleware.DeleteRedirectToCookie(ctx.Resp) if obeyRedirect { - ctx.RedirectToFirst(redirectTo) + ctx.RedirectToCurrentSite(redirectTo) } return redirectTo } @@ -353,14 +382,14 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe return setting.AppSubURL + "/" } -func getUserName(gothUser *goth.User) string { +func getUserName(gothUser *goth.User) (string, error) { switch setting.OAuth2Client.Username { case setting.OAuth2UsernameEmail: - return strings.Split(gothUser.Email, "@")[0] + return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0]) case setting.OAuth2UsernameNickname: - return gothUser.NickName + return user_model.NormalizeUserName(gothUser.NickName) default: // OAuth2UsernameUserid - return gothUser.UserID + return gothUser.UserID, nil } } @@ -368,7 +397,6 @@ func getUserName(gothUser *goth.User) string { func HandleSignOut(ctx *context.Context) { _ = ctx.Session.Flush() _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) - ctx.DeleteSiteCookie(setting.CookieUserName) ctx.DeleteSiteCookie(setting.CookieRememberName) ctx.Csrf.DeleteCookie(ctx) middleware.DeleteRedirectToCookie(ctx.Resp) @@ -392,13 +420,12 @@ func SignUp(ctx *context.Context) { ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" - orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) if err != nil { ctx.ServerError("UserSignUp", err) return } - ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names ctx.Data["OAuth2Providers"] = oauth2Providers context.SetCaptchaData(ctx) @@ -422,13 +449,12 @@ func SignUpPost(ctx *context.Context) { ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" - orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) if err != nil { ctx.ServerError("UserSignUp", err) return } - ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names ctx.Data["OAuth2Providers"] = oauth2Providers context.SetCaptchaData(ctx) @@ -470,10 +496,9 @@ func SignUpPost(ctx *context.Context) { ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplSignUp, &form) return } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { + if err := password.IsPwned(ctx, form.Password); err != nil { errMsg := ctx.Tr("auth.password_pwned") - if err != nil { + if password.IsErrIsPwnedRequest(err) { log.Error(err.Error()) errMsg = ctx.Tr("auth.password_pwned_err") } @@ -577,10 +602,12 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { // Auto-set admin for the only user. if user_model.CountUsers(ctx, nil) == 1 { - u.IsAdmin = true - u.IsActive = true - u.SetLastLogin() - if err := user_model.UpdateUserCols(ctx, u, "is_admin", "is_active", "last_login_unix"); err != nil { + opts := &user_service.UpdateOptions{ + IsActive: optional.Some(true), + IsAdmin: optional.Some(true), + SetLastLogin: true, + } + if err := user_service.UpdateUser(ctx, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return false } @@ -588,83 +615,94 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. // update external user information if gothUser != nil { - if err := externalaccount.UpdateExternalUser(u, *gothUser); err != nil { + if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil { if !errors.Is(err, util.ErrNotExist) { log.Error("UpdateExternalUser failed: %v", err) } } } - // Send confirmation email - if !u.IsActive && u.ID > 1 { - if setting.Service.RegisterManualConfirm { - ctx.Data["ManualActivationOnly"] = true - ctx.HTML(http.StatusOK, TplActivate) - return false - } + // for active user or the first (admin) user, we don't need to send confirmation email + if u.IsActive || u.ID == 1 { + return true + } - mailer.SendActivateAccountMail(ctx.Locale, u) + if setting.Service.RegisterManualConfirm { + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only")) + return false + } - ctx.Data["IsSendRegisterMail"] = true - ctx.Data["Email"] = u.Email - ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) - ctx.HTML(http.StatusOK, TplActivate) + sendActivateEmail(ctx, u) + return false +} - if setting.CacheService.Enabled { - if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { - log.Error("Set cache(MailResendLimit) fail: %v", err) - } - } - return false +func renderActivationPromptMessage(ctx *context.Context, msg template.HTML) { + ctx.Data["ActivationPromptMessage"] = msg + ctx.HTML(http.StatusOK, TplActivatePrompt) +} + +func sendActivateEmail(ctx *context.Context, u *user_model.User) { + if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) { + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt")) + return } - return true + if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.resent_limit_prompt")) + return + } + + mailer.SendActivateAccountMail(ctx.Locale, u) + + activeCodeLives := timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) + msgHTML := ctx.Locale.Tr("auth.confirmation_mail_sent_prompt_ex", u.Email, activeCodeLives) + renderActivationPromptMessage(ctx, msgHTML) +} + +func renderActivationVerifyPassword(ctx *context.Context, code string) { + ctx.Data["ActivationCode"] = code + ctx.Data["NeedVerifyLocalPassword"] = true + ctx.HTML(http.StatusOK, TplActivate) +} + +func renderActivationChangeEmail(ctx *context.Context) { + ctx.HTML(http.StatusOK, TplActivate) } // Activate render activate user page func Activate(ctx *context.Context) { code := ctx.FormString("code") - if len(code) == 0 { - ctx.Data["IsActivatePage"] = true - if ctx.Doer == nil || ctx.Doer.IsActive { - ctx.NotFound("invalid user", nil) + if code == "" { + if ctx.Doer == nil { + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } else if ctx.Doer.IsActive { + ctx.Redirect(setting.AppSubURL + "/") return } - // Resend confirmation email. - if setting.Service.RegisterEmailConfirm { - if setting.CacheService.Enabled && ctx.Cache.IsExist("MailResendLimit_"+ctx.Doer.LowerName) { - ctx.Data["ResendLimited"] = true - } else { - ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) - mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) - if setting.CacheService.Enabled { - if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { - log.Error("Set cache(MailResendLimit) fail: %v", err) - } - } - } - } else { - ctx.Data["ServiceNotEnabled"] = true + if setting.MailService == nil || !setting.Service.RegisterEmailConfirm { + renderActivationPromptMessage(ctx, ctx.Tr("auth.disable_register_mail")) + return } - ctx.HTML(http.StatusOK, TplActivate) + + // Resend confirmation email. FIXME: ideally this should be in a POST request + sendActivateEmail(ctx, ctx.Doer) return } + // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated user := user_model.VerifyUserActiveCode(ctx, code) - // if code is wrong - if user == nil { - ctx.Data["IsCodeInvalid"] = true - ctx.HTML(http.StatusOK, TplActivate) + if user == nil { // if code is wrong + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) return } // if account is local account, verify password if user.LoginSource == 0 { - ctx.Data["Code"] = code - ctx.Data["NeedsPassword"] = true - ctx.HTML(http.StatusOK, TplActivate) + renderActivationVerifyPassword(ctx, code) return } @@ -674,31 +712,49 @@ func Activate(ctx *context.Context) { // ActivatePost handles account activation with password check func ActivatePost(ctx *context.Context) { code := ctx.FormString("code") - if len(code) == 0 { + if ctx.Doer != nil && ctx.Doer.IsActive { + ctx.Redirect(setting.AppSubURL + "/user/activate") // it will redirect again to the correct page + return + } + + if code == "" { + newEmail := strings.TrimSpace(ctx.FormString("change_email")) + if ctx.Doer != nil && newEmail != "" && !strings.EqualFold(ctx.Doer.Email, newEmail) { + if user_model.ValidateEmail(newEmail) != nil { + ctx.Flash.Error(ctx.Locale.Tr("form.email_invalid"), true) + renderActivationChangeEmail(ctx) + return + } + err := user_model.ChangeInactivePrimaryEmail(ctx, ctx.Doer.ID, ctx.Doer.Email, newEmail) + if err != nil { + ctx.Flash.Error(ctx.Locale.Tr("admin.emails.not_updated", newEmail), true) + renderActivationChangeEmail(ctx) + return + } + ctx.Doer.Email = newEmail + } + // FIXME: at the moment, GET request handles the "send confirmation email" action. But the old code does this redirect and then send a confirmation email. ctx.Redirect(setting.AppSubURL + "/user/activate") return } + // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated user := user_model.VerifyUserActiveCode(ctx, code) - // if code is wrong - if user == nil { - ctx.Data["IsCodeInvalid"] = true - ctx.HTML(http.StatusOK, TplActivate) + if user == nil { // if code is wrong + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) return } // if account is local account, verify password if user.LoginSource == 0 { password := ctx.FormString("password") - if len(password) == 0 { - ctx.Data["Code"] = code - ctx.Data["NeedsPassword"] = true - ctx.HTML(http.StatusOK, TplActivate) + if password == "" { + renderActivationVerifyPassword(ctx, code) return } if !user.ValidatePassword(password) { - ctx.Data["IsPasswordInvalid"] = true - ctx.HTML(http.StatusOK, TplActivate) + ctx.Flash.Error(ctx.Locale.Tr("auth.invalid_password"), true) + renderActivationVerifyPassword(ctx, code) return } } @@ -744,17 +800,15 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { return } - // Register last login - user.SetLastLogin() - if err := user_model.UpdateUserCols(ctx, user, "last_login_unix"); err != nil { - ctx.ServerError("UpdateUserCols", err) + if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + ctx.ServerError("UpdateUser", err) return } ctx.Flash.Success(ctx.Tr("auth.account_activated")) if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo) + ctx.RedirectToCurrentSite(redirectTo) return } @@ -777,7 +831,7 @@ func ActivateEmail(ctx *context.Context) { if u, err := user_model.GetUserByID(ctx, email.UID); err != nil { log.Warn("GetUserByID: %d", email.UID) - } else if setting.CacheService.Enabled { + } else { // Allow user to validate more emails _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) } diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go new file mode 100644 index 00000000000..c6afbf877c0 --- /dev/null +++ b/routers/web/auth/auth_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestUserLogin(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/user/login") + SignIn(ctx) + assert.Equal(t, http.StatusOK, resp.Code) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "/", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to=/other") + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login") + ctx.Req.AddCookie(&http.Cookie{Name: "redirect_to", Value: "/other-cookie"}) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/other-cookie", test.RedirectURL(resp)) + + ctx, resp = contexttest.MockContext(t, "/user/login?redirect_to="+url.QueryEscape("https://example.com")) + ctx.IsSigned = true + SignIn(ctx) + assert.Equal(t, "/", test.RedirectURL(resp)) +} diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index 745b4e818cb..f744a57a43f 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -12,13 +12,13 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" @@ -55,7 +55,11 @@ func LinkAccount(ctx *context.Context) { } gu, _ := gothUser.(goth.User) - uname := getUserName(&gu) + uname, err := getUserName(&gu) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } email := gu.Email ctx.Data["user_name"] = uname ctx.Data["email"] = email @@ -152,7 +156,7 @@ func LinkAccountPostSignIn(ctx *context.Context) { } func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { - updateAvatarIfNeed(gothUser.AvatarURL, u) + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) // If this user is enrolled in 2FA, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. @@ -164,7 +168,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r return } - err = externalaccount.LinkAccountToUser(u, gothUser) + err = externalaccount.LinkAccountToUser(ctx, u, gothUser) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -185,7 +189,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r } // If WebAuthn is enrolled -> Redirect to WebAuthn instead - regs, err := auth.GetWebAuthnCredentialsByUID(u.ID) + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) if err == nil && len(regs) > 0 { ctx.Redirect(setting.AppSubURL + "/user/webauthn") return @@ -271,7 +275,7 @@ func LinkAccountPostRegister(ctx *context.Context) { } } - authSource, err := auth.GetActiveOAuth2SourceByName(gothUser.Provider) + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) if err != nil { ctx.ServerError("CreateUser", err) return diff --git a/routers/web/auth/main_test.go b/routers/web/auth/main_test.go index 8295515ba95..b438e5d5188 100644 --- a/routers/web/auth/main_test.go +++ b/routers/web/auth/main_test.go @@ -4,14 +4,11 @@ package auth import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 640c01e203b..3189d1372e2 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "html" + "html/template" "io" "net/http" "net/url" @@ -21,9 +22,9 @@ import ( auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -32,6 +33,7 @@ import ( auth_service "code.gitea.io/gitea/services/auth" source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" user_service "code.gitea.io/gitea/services/user" @@ -237,7 +239,7 @@ func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, ser idToken.EmailVerified = user.IsActive } if grant.ScopeContains("groups") { - groups, err := getOAuthGroupsForUser(user) + groups, err := getOAuthGroupsForUser(ctx, user) if err != nil { log.Error("Error getting groups: %v", err) return nil, &AccessTokenError{ @@ -291,7 +293,7 @@ func InfoOAuth(ctx *context.Context) { Picture: ctx.Doer.AvatarLink(ctx), } - groups, err := getOAuthGroupsForUser(ctx.Doer) + groups, err := getOAuthGroupsForUser(ctx, ctx.Doer) if err != nil { ctx.ServerError("Oauth groups for user", err) return @@ -303,8 +305,8 @@ func InfoOAuth(ctx *context.Context) { // returns a list of "org" and "org:team" strings, // that the given user is a part of. -func getOAuthGroupsForUser(user *user_model.User) ([]string, error) { - orgs, err := org_model.GetUserOrgsList(user) +func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]string, error) { + orgs, err := org_model.GetUserOrgsList(ctx, user) if err != nil { return nil, fmt.Errorf("GetUserOrgList: %w", err) } @@ -312,12 +314,12 @@ func getOAuthGroupsForUser(user *user_model.User) ([]string, error) { var groups []string for _, org := range orgs { groups = append(groups, org.Name) - teams, err := org.LoadTeams() + teams, err := org.LoadTeams(ctx) if err != nil { return nil, fmt.Errorf("LoadTeams: %w", err) } for _, team := range teams { - if team.IsMember(user.ID) { + if team.IsMember(ctx, user.ID) { groups = append(groups, org.Name+":"+team.LowerName) } } @@ -498,11 +500,11 @@ func AuthorizeOAuth(ctx *context.Context) { ctx.Data["Scope"] = form.Scope ctx.Data["Nonce"] = form.Nonce if user != nil { - ctx.Data["ApplicationCreatorLinkHTML"] = fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)) + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))) } else { - ctx.Data["ApplicationCreatorLinkHTML"] = fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)) + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))) } - ctx.Data["ApplicationRedirectDomainHTML"] = "" + html.EscapeString(form.RedirectURI) + "" + ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "") // TODO document SESSION <=> FORM err = ctx.Session.Set("client_id", app.ClientID) if err != nil { @@ -578,16 +580,8 @@ func GrantApplicationOAuth(ctx *context.Context) { // OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities func OIDCWellKnown(ctx *context.Context) { - t, err := ctx.Render.TemplateLookup("user/auth/oidc_wellknown", nil) - if err != nil { - ctx.ServerError("unable to find template", err) - return - } - ctx.Resp.Header().Set("Content-Type", "application/json") ctx.Data["SigningKey"] = oauth2.DefaultSigningKey - if err = t.Execute(ctx.Resp, ctx.Data); err != nil { - ctx.ServerError("unable to execute template", err) - } + ctx.JSONTemplate("user/auth/oidc_wellknown") } // OIDCKeys generates the JSON Web Key Set @@ -847,7 +841,7 @@ func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirect func SignInOAuth(ctx *context.Context) { provider := ctx.Params(":provider") - authSource, err := auth.GetActiveOAuth2SourceByName(provider) + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) if err != nil { ctx.ServerError("SignIn", err) return @@ -868,7 +862,7 @@ func SignInOAuth(ctx *context.Context) { if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil { if strings.Contains(err.Error(), "no provider for ") { - if err = oauth2.ResetOAuth2(); err != nil { + if err = oauth2.ResetOAuth2(ctx); err != nil { ctx.ServerError("SignIn", err) return } @@ -898,7 +892,7 @@ func SignInOAuthCallback(ctx *context.Context) { } // first look if the provider is still active - authSource, err := auth.GetActiveOAuth2SourceByName(provider) + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) if err != nil { ctx.ServerError("SignIn", err) return @@ -941,7 +935,7 @@ func SignInOAuthCallback(ctx *context.Context) { if u == nil { if ctx.Doer != nil { // attach user to already logged in user - err = externalaccount.LinkAccountToUser(ctx.Doer, gothUser) + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -970,8 +964,13 @@ func SignInOAuthCallback(ctx *context.Context) { ctx.ServerError("CreateUser", err) return } + uname, err := getUserName(&gothUser) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } u = &user_model.User{ - Name: getUserName(&gothUser), + Name: uname, FullName: gothUser.Name, Email: gothUser.Email, LoginType: auth.OAuth2, @@ -980,12 +979,14 @@ func SignInOAuthCallback(ctx *context.Context) { } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolOf(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), + IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), } source := authSource.Cfg.(*oauth2.Source) - setUserAdminAndRestrictedFromGroupClaims(source, u, &gothUser) + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) + u.IsAdmin = isAdmin.ValueOrDefault(false) + u.IsRestricted = isRestricted.ValueOrDefault(false) if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { // error already handled @@ -1049,19 +1050,17 @@ func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[ return claimValueToStringSet(groupClaims) } -func setUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, u *user_model.User, gothUser *goth.User) bool { +func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { groups := getClaimedGroups(source, gothUser) - wasAdmin, wasRestricted := u.IsAdmin, u.IsRestricted - if source.AdminGroup != "" { - u.IsAdmin = groups.Contains(source.AdminGroup) + isAdmin = optional.Some(groups.Contains(source.AdminGroup)) } if source.RestrictedGroup != "" { - u.IsRestricted = groups.Contains(source.RestrictedGroup) + isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) } - return wasAdmin != u.IsAdmin || wasRestricted != u.IsRestricted + return isAdmin, isRestricted } func showLinkingLogin(ctx *context.Context, gothUser goth.User) { @@ -1074,7 +1073,7 @@ func showLinkingLogin(ctx *context.Context, gothUser goth.User) { ctx.Redirect(setting.AppSubURL + "/user/link_account") } -func updateAvatarIfNeed(url string, u *user_model.User) { +func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { resp, err := http.Get(url) if err == nil { @@ -1086,14 +1085,14 @@ func updateAvatarIfNeed(url string, u *user_model.User) { if err == nil && resp.StatusCode == http.StatusOK { data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { - _ = user_service.UploadAvatar(u, data) + _ = user_service.UploadAvatar(ctx, u, data) } } } } func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { - updateAvatarIfNeed(gothUser.AvatarURL, u) + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) needs2FA := false if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { @@ -1128,18 +1127,12 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // Clear whatever CSRF cookie has right now, force to generate a new one ctx.Csrf.DeleteCookie(ctx) - // Register last login - u.SetLastLogin() - - // Update GroupClaims - changed := setUserAdminAndRestrictedFromGroupClaims(oauth2Source, u, &gothUser) - cols := []string{"last_login_unix"} - if changed { - cols = append(cols, "is_admin", "is_restricted") + opts := &user_service.UpdateOptions{ + SetLastLogin: true, } - - if err := user_model.UpdateUserCols(ctx, u, cols...); err != nil { - ctx.ServerError("UpdateUserCols", err) + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) return } @@ -1151,7 +1144,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } // update external user information - if err := externalaccount.UpdateExternalUser(u, gothUser); err != nil { + if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { if !errors.Is(err, util.ErrNotExist) { log.Error("UpdateExternalUser failed: %v", err) } @@ -1164,7 +1157,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo) + ctx.RedirectToCurrentSite(redirectTo) return } @@ -1172,10 +1165,11 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } - changed := setUserAdminAndRestrictedFromGroupClaims(oauth2Source, u, &gothUser) - if changed { - if err := user_model.UpdateUserCols(ctx, u, "is_admin", "is_restricted"); err != nil { - ctx.ServerError("UpdateUserCols", err) + opts := &user_service.UpdateOptions{} + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) return } } @@ -1197,7 +1191,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } // If WebAuthn is enrolled -> Redirect to WebAuthn instead - regs, err := auth.GetWebAuthnCredentialsByUID(u.ID) + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) if err == nil && len(regs) > 0 { ctx.Redirect(setting.AppSubURL + "/user/webauthn") return @@ -1274,7 +1268,7 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ ExternalID: gothUser.UserID, LoginSourceID: authSource.ID, } - hasUser, err = user_model.GetExternalLogin(externalLoginUser) + hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser) if err != nil { return nil, goth.User{}, err } diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index aa071296325..2143b8096a0 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -11,13 +11,12 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/openid" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -36,23 +35,7 @@ func SignInOpenID(ctx *context.Context) { return } - // Check auto-login. - isSucceed, err := AutoSignIn(ctx) - if err != nil { - ctx.ServerError("AutoSignIn", err) - return - } - - redirectTo := ctx.FormString("redirect_to") - if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) - } else { - redirectTo = ctx.GetSiteCookie("redirect_to") - } - - if isSucceed { - middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo) + if CheckAutoLogin(ctx) { return } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index bdfa8c40258..0e88fe68f92 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -12,15 +12,16 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" - "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" ) var ( @@ -35,7 +36,7 @@ func ForgotPasswd(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") if setting.MailService == nil { - log.Warn(ctx.Tr("auth.disable_forgot_password_mail_admin")) + log.Warn("no mail service configured") ctx.Data["IsResetDisable"] = true ctx.HTML(http.StatusOK, tplForgotPassword) return @@ -79,7 +80,7 @@ func ForgotPasswdPost(ctx *context.Context) { return } - if setting.CacheService.Enabled && ctx.Cache.IsExist("MailResendLimit_"+u.LowerName) { + if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) { ctx.Data["ResendLimited"] = true ctx.HTML(http.StatusOK, tplForgotPassword) return @@ -87,10 +88,8 @@ func ForgotPasswdPost(ctx *context.Context) { mailer.SendResetPasswordMail(u) - if setting.CacheService.Enabled { - if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { - log.Error("Set cache(MailResendLimit) fail: %v", err) - } + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) } ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) @@ -167,30 +166,6 @@ func ResetPasswdPost(ctx *context.Context) { return } - // Validate password length. - passwd := ctx.FormString("password") - if len(passwd) < setting.MinPasswordLength { - ctx.Data["IsResetForm"] = true - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) - return - } else if !password.IsComplexEnough(passwd) { - ctx.Data["IsResetForm"] = true - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil) - return - } else if pwned, err := password.IsPwned(ctx, passwd); pwned || err != nil { - errMsg := ctx.Tr("auth.password_pwned") - if err != nil { - log.Error(err.Error()) - errMsg = ctx.Tr("auth.password_pwned_err") - } - ctx.Data["IsResetForm"] = true - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(errMsg, tplResetPassword, nil) - return - } - // Handle two-factor regenerateScratchToken := false if twofa != nil { @@ -223,18 +198,26 @@ func ResetPasswdPost(ctx *context.Context) { } } } - var err error - if u.Rands, err = user_model.GetUserSalt(); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - if err = u.SetPassword(passwd); err != nil { - ctx.ServerError("UpdateUser", err) - return + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(ctx.FormString("password")), + MustChangePassword: optional.Some(false), } - u.MustChangePassword = false - if err := user_model.UpdateUserCols(ctx, u, "must_change_password", "passwd", "passwd_hash_algo", "rands", "salt"); err != nil { - ctx.ServerError("UpdateUser", err) + if err := user_service.UpdateAuth(ctx, u, opts); err != nil { + ctx.Data["IsResetForm"] = true + ctx.Data["Err_Password"] = true + switch { + case errors.Is(err, password.ErrMinLength): + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil) + case errors.Is(err, password.ErrComplexity): + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplResetPassword, nil) + case errors.Is(err, password.ErrIsPwned): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned"), tplResetPassword, nil) + case password.IsErrIsPwnedRequest(err): + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil) + default: + ctx.ServerError("UpdateAuth", err) + } return } @@ -244,7 +227,7 @@ func ResetPasswdPost(ctx *context.Context) { if regenerateScratchToken { // Invalidate the scratch token. - _, err = twofa.GenerateScratchToken() + _, err := twofa.GenerateScratchToken() if err != nil { ctx.ServerError("UserSignIn", err) return @@ -284,11 +267,11 @@ func MustChangePasswordPost(ctx *context.Context) { ctx.HTML(http.StatusOK, tplMustChangePassword) return } - u := ctx.Doer + // Make sure only requests for users who are eligible to change their password via // this method passes through - if !u.MustChangePassword { - ctx.ServerError("MustUpdatePassword", errors.New("cannot update password.. Please visit the settings page")) + if !ctx.Doer.MustChangePassword { + ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page")) return } @@ -298,48 +281,37 @@ func MustChangePasswordPost(ctx *context.Context) { return } - if len(form.Password) < setting.MinPasswordLength { - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) - return - } - - if !password.IsComplexEnough(form.Password) { - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form) - return + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), } - pwned, err := password.IsPwned(ctx, form.Password) - if pwned { - ctx.Data["Err_Password"] = true - errMsg := ctx.Tr("auth.password_pwned") - if err != nil { - log.Error(err.Error()) - errMsg = ctx.Tr("auth.password_pwned_err") + if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned"), tplMustChangePassword, &form) + case password.IsErrIsPwnedRequest(err): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplMustChangePassword, &form) + default: + ctx.ServerError("UpdateAuth", err) } - ctx.RenderWithErr(errMsg, tplMustChangePassword, &form) - return - } - - if err = u.SetPassword(form.Password); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - - u.MustChangePassword = false - - if err := user_model.UpdateUserCols(ctx, u, "must_change_password", "passwd", "passwd_hash_algo", "salt"); err != nil { - ctx.ServerError("UpdateUser", err) return } ctx.Flash.Success(ctx.Tr("settings.change_password_success")) - log.Trace("User updated password: %s", u.Name) + log.Trace("User updated password: %s", ctx.Doer.Name) - if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) { + if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" { middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo) + ctx.RedirectToCurrentSite(redirectTo) return } diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index 013e11eacce..1079f44a085 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -11,9 +11,9 @@ import ( user_model "code.gitea.io/gitea/models/user" wa "code.gitea.io/gitea/modules/auth/webauthn" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "github.com/go-webauthn/webauthn/protocol" @@ -26,8 +26,7 @@ var tplWebAuthn base.TplName = "user/auth/webauthn" func WebAuthn(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } @@ -37,6 +36,14 @@ func WebAuthn(ctx *context.Context) { return } + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Session.Get("twofaUid").(int64)) + if err != nil { + ctx.ServerError("HasTwoFactorByUID", err) + return + } + + ctx.Data["HasTwoFactor"] = hasTwoFactor + ctx.HTML(http.StatusOK, tplWebAuthn) } @@ -55,7 +62,7 @@ func WebAuthnLoginAssertion(ctx *context.Context) { return } - exists, err := auth.ExistsWebAuthnCredentialsForUID(user.ID) + exists, err := auth.ExistsWebAuthnCredentialsForUID(ctx, user.ID) if err != nil { ctx.ServerError("UserSignIn", err) return @@ -127,21 +134,21 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) { } // Success! Get the credential and update the sign count with the new value we received. - dbCred, err := auth.GetWebAuthnCredentialByCredID(user.ID, cred.ID) + dbCred, err := auth.GetWebAuthnCredentialByCredID(ctx, user.ID, cred.ID) if err != nil { ctx.ServerError("GetWebAuthnCredentialByCredID", err) return } dbCred.SignCount = cred.Authenticator.SignCount - if err := dbCred.UpdateSignCount(); err != nil { + if err := dbCred.UpdateSignCount(ctx); err != nil { ctx.ServerError("UpdateSignCount", err) return } // Now handle account linking if that's requested if ctx.Session.Get("linkAccount") != nil { - if err := externalaccount.LinkAccountFromStore(ctx.Session, user); err != nil { + if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { ctx.ServerError("LinkAccountFromStore", err) return } diff --git a/routers/web/auth_windows.go b/routers/web/auth_windows.go deleted file mode 100644 index 3125d7ce9a7..00000000000 --- a/routers/web/auth_windows.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package web - -import ( - "code.gitea.io/gitea/models/auth" - auth_service "code.gitea.io/gitea/services/auth" -) - -// specialAdd registers the SSPI auth method as the last method in the list. -// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation -// fails (or if negotiation should continue), which would prevent other authentication methods -// to execute at all. -func specialAdd(group *auth_service.Group) { - if auth.IsSSPIEnabled() { - group.Add(&auth_service.SSPI{}) - } -} diff --git a/routers/web/base.go b/routers/web/base.go index e4b7d8ce8db..78dde57fa6f 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -19,81 +19,80 @@ import ( "code.gitea.io/gitea/modules/web/routing" ) -func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) func(next http.Handler) http.Handler { +func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc { prefix = strings.Trim(prefix, "/") funcInfo := routing.GetFuncInfo(storageHandler, prefix) - return func(next http.Handler) http.Handler { - if storageSetting.MinioConfig.ServeDirect { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" && req.Method != "HEAD" { - next.ServeHTTP(w, req) - return - } - - if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { - next.ServeHTTP(w, req) - return - } - routing.UpdateFuncInfo(req.Context(), funcInfo) - - rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") - rPath = util.PathJoinRelX(rPath) - - u, err := objStore.URL(rPath, path.Base(rPath)) - if err != nil { - if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { - log.Warn("Unable to find %s %s", prefix, rPath) - http.Error(w, "file not found", http.StatusNotFound) - return - } - log.Error("Error whilst getting URL for %s %s. Error: %v", prefix, rPath, err) - http.Error(w, fmt.Sprintf("Error whilst getting URL for %s %s", prefix, rPath), http.StatusInternalServerError) - return - } - - http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) - }) - } + if storageSetting.MinioConfig.ServeDirect { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.Method != "GET" && req.Method != "HEAD" { - next.ServeHTTP(w, req) + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { - next.ServeHTTP(w, req) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } routing.UpdateFuncInfo(req.Context(), funcInfo) rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") rPath = util.PathJoinRelX(rPath) - if rPath == "" || rPath == "." { - http.Error(w, "file not found", http.StatusNotFound) - return - } - fi, err := objStore.Stat(rPath) + u, err := objStore.URL(rPath, path.Base(rPath)) if err != nil { if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { log.Warn("Unable to find %s %s", prefix, rPath) - http.Error(w, "file not found", http.StatusNotFound) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } - log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) - http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + log.Error("Error whilst getting URL for %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst getting URL for %s %s", prefix, rPath), http.StatusInternalServerError) return } - fr, err := objStore.Open(rPath) - if err != nil { - log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) - http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) - return - } - defer fr.Close() - httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) + http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) }) } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != "GET" && req.Method != "HEAD" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if !strings.HasPrefix(req.URL.Path, "/"+prefix+"/") { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + routing.UpdateFuncInfo(req.Context(), funcInfo) + + rPath := strings.TrimPrefix(req.URL.Path, "/"+prefix+"/") + rPath = util.PathJoinRelX(rPath) + if rPath == "" || rPath == "." { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + fi, err := objStore.Stat(rPath) + if err != nil { + if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { + log.Warn("Unable to find %s %s", prefix, rPath) + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + + fr, err := objStore.Open(rPath) + if err != nil { + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) + return + } + defer fr.Close() + httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) + }) } diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index 525ca9be53c..dd20663f94b 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -10,8 +10,8 @@ import ( "time" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" ) // List all devtest templates, they will be used for e2e tests for the UI components diff --git a/routers/web/events/events.go b/routers/web/events/events.go index 1a5a162c1a2..52f20e07dc3 100644 --- a/routers/web/events/events.go +++ b/routers/web/events/events.go @@ -7,11 +7,11 @@ import ( "net/http" "time" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/routers/web/auth" + "code.gitea.io/gitea/services/context" ) // Events listens for events diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index 94d83818fc6..ecd7c33e016 100644 --- a/routers/web/explore/code.go +++ b/routers/web/explore/code.go @@ -6,11 +6,12 @@ package explore import ( "net/http" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" code_indexer "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) const ( @@ -34,12 +35,11 @@ func Code(ctx *context.Context) { language := ctx.FormTrim("l") keyword := ctx.FormTrim("q") - queryType := ctx.FormTrim("t") - isMatch := queryType == "match" + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) ctx.Data["Keyword"] = keyword ctx.Data["Language"] = language - ctx.Data["queryType"] = queryType + ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["PageIsViewCode"] = true if keyword == "" { @@ -77,7 +77,16 @@ func Code(ctx *context.Context) { ) if (len(repoIDs) > 0) || isAdmin { - total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) if err != nil { if code_indexer.IsAvailable(ctx) { ctx.ServerError("SearchResults", err) @@ -102,7 +111,7 @@ func Code(ctx *context.Context) { } } - repoMaps, err := repo_model.GetRepositoriesMapByIDs(loadRepoIDs) + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) if err != nil { ctx.ServerError("GetRepositoriesMapByIDs", err) return @@ -128,7 +137,7 @@ func Code(ctx *context.Context) { pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "l", "Language") + pager.AddParamString("l", language) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplExploreCode) diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index e37bce6b40e..f8fd6ec38ef 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -6,9 +6,10 @@ package explore import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" ) // Organizations render explore organizations page @@ -24,8 +25,16 @@ func Organizations(ctx *context.Context) { visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate) } - if ctx.FormString("sort") == "" { - ctx.SetFormString("sort", UserSearchDefaultSortType) + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) } RenderUserSearch(ctx, &user_model.SearchUserOptions{ @@ -33,5 +42,7 @@ func Organizations(ctx *context.Context) { Type: user_model.UserTypeOrganization, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, Visible: visibleTypes, + + SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) } diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index e5f7977abd0..66477a255cc 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -10,10 +10,10 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/services/context" ) const ( @@ -57,8 +57,13 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { orderBy db.SearchOrderBy ) - ctx.Data["SortType"] = ctx.FormString("sort") - switch ctx.FormString("sort") { + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { case "newest": orderBy = db.SearchOrderByNewest case "oldest": @@ -104,6 +109,21 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { language := ctx.FormTrim("language") ctx.Data["Language"] = language + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: page, @@ -120,6 +140,11 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { Language: language, IncludeDescription: setting.UI.SearchRepoDescription, OnlyShowRelevant: opts.OnlyShowRelevant, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -144,8 +169,8 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { pager := context.NewPagination(int(count), opts.PageSize, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "topic", "TopicOnly") - pager.AddParam(ctx, "language", "Language") + pager.AddParamString("topic", fmt.Sprint(topicOnly)) + pager.AddParamString("language", language) pager.AddParamString(relevantReposOnlyParam, fmt.Sprint(opts.OnlyShowRelevant)) ctx.Data["Page"] = pager diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go index 132ef23fa75..b4507ba28d4 100644 --- a/routers/web/explore/topic.go +++ b/routers/web/explore/topic.go @@ -8,8 +8,8 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -23,7 +23,7 @@ func TopicSearch(ctx *context.Context) { }, } - topics, total, err := repo_model.FindTopics(opts) + topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError) return diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index c760004088e..b79a79fb2c8 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -10,12 +10,13 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sitemap" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -23,12 +24,6 @@ const ( tplExploreUsers base.TplName = "explore/users" ) -// UserSearchDefaultSortType is the default sort type for user search -const ( - UserSearchDefaultSortType = "recentupdate" - UserSearchDefaultAdminSort = "alphabetically" -) - var nullByte = []byte{0x00} func isKeywordValid(keyword string) bool { @@ -60,8 +55,13 @@ func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns - ctx.Data["SortType"] = ctx.FormString("sort") - switch ctx.FormString("sort") { + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { case "newest": orderBy = "`user`.id DESC" case "oldest": @@ -80,10 +80,16 @@ func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, fallthrough default: // in case the sortType is not valid, we set it to recentupdate + sortOrder = "recentupdate" ctx.Data["SortType"] = "recentupdate" orderBy = "`user`.updated_unix DESC" } + if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) { + ctx.NotFound("unsupported sort order", nil) + return + } + opts.Keyword = ctx.FormTrim("q") opts.OrderBy = orderBy if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { @@ -133,15 +139,25 @@ func Users(ctx *context.Context) { ctx.Data["PageIsExploreUsers"] = true ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - if ctx.FormString("sort") == "" { - ctx.SetFormString("sort", UserSearchDefaultSortType) + supportedSortOrders := container.SetOf( + "newest", + "oldest", + "alphabetically", + "reversealphabetically", + ) + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = "newest" + ctx.SetFormString("sort", sortOrder) } RenderUserSearch(ctx, &user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, + + SupportedSortOrders: supportedSortOrders, }, tplExploreUsers) } diff --git a/routers/web/feed/branch.go b/routers/web/feed/branch.go index f13038ff9b4..80ce2ad198b 100644 --- a/routers/web/feed/branch.go +++ b/routers/web/feed/branch.go @@ -9,7 +9,7 @@ import ( "time" "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index 3775ba495a0..3defa436a70 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -6,6 +6,7 @@ package feed import ( "fmt" "html" + "html/template" "net/http" "net/url" "strconv" @@ -13,55 +14,57 @@ import ( activities_model "code.gitea.io/gitea/models/activities" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) -func toBranchLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/src/branch/" + util.PathEscapeSegments(act.GetBranch()) +func toBranchLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/branch/" + util.PathEscapeSegments(act.GetBranch()) } -func toTagLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/src/tag/" + util.PathEscapeSegments(act.GetTag()) +func toTagLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/tag/" + util.PathEscapeSegments(act.GetTag()) } -func toIssueLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/issues/" + url.PathEscape(act.GetIssueInfos()[0]) +func toIssueLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/issues/" + url.PathEscape(act.GetIssueInfos()[0]) } -func toPullLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/pulls/" + url.PathEscape(act.GetIssueInfos()[0]) +func toPullLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/pulls/" + url.PathEscape(act.GetIssueInfos()[0]) } -func toSrcLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/src/" + util.PathEscapeSegments(act.GetBranch()) +func toSrcLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/src/" + util.PathEscapeSegments(act.GetBranch()) } -func toReleaseLink(act *activities_model.Action) string { - return act.GetRepoAbsoluteLink() + "/releases/tag/" + util.PathEscapeSegments(act.GetBranch()) +func toReleaseLink(ctx *context.Context, act *activities_model.Action) string { + return act.GetRepoAbsoluteLink(ctx) + "/releases/tag/" + util.PathEscapeSegments(act.GetBranch()) } // renderMarkdown creates a minimal markdown render context from an action. // If rendering fails, the original markdown text is returned -func renderMarkdown(ctx *context.Context, act *activities_model.Action, content string) string { +func renderMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML { markdownCtx := &markup.RenderContext{ - Ctx: ctx, - URLPrefix: act.GetRepoLink(), - Type: markdown.MarkupName, + Ctx: ctx, + Links: markup.Links{ + Base: act.GetRepoLink(ctx), + }, + Type: markdown.MarkupName, Metas: map[string]string{ - "user": act.GetRepoUserName(), - "repo": act.GetRepoName(), + "user": act.GetRepoUserName(ctx), + "repo": act.GetRepoName(ctx), }, } markdown, err := markdown.RenderString(markdownCtx, content) if err != nil { - return content + return templates.SanitizeHTML(content) // old code did so: use SanitizeHTML to render in tmpl } return markdown } @@ -71,125 +74,130 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio for _, act := range actions { act.LoadActUser(ctx) - var content, desc, title string + // TODO: the code seems quite strange (maybe not right) + // sometimes it uses text content but sometimes it uses HTML content + // it should clearly defines which kind of content it should use for the feed items: plan text or rich HTML + var title, desc string + var content template.HTML - link := &feeds.Link{Href: act.GetCommentHTMLURL()} + link := &feeds.Link{Href: act.GetCommentHTMLURL(ctx)} // title title = act.ActUser.DisplayName() + " " + var titleExtra template.HTML switch act.OpType { case activities_model.ActionCreateRepo: - title += ctx.TrHTMLEscapeArgs("action.create_repo", act.GetRepoAbsoluteLink(), act.ShortRepoPath()) - link.Href = act.GetRepoAbsoluteLink() + titleExtra = ctx.Locale.Tr("action.create_repo", act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) case activities_model.ActionRenameRepo: - title += ctx.TrHTMLEscapeArgs("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(), act.ShortRepoPath()) - link.Href = act.GetRepoAbsoluteLink() + titleExtra = ctx.Locale.Tr("action.rename_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) + link.Href = act.GetRepoAbsoluteLink(ctx) case activities_model.ActionCommitRepo: - link.Href = toBranchLink(act) + link.Href = toBranchLink(ctx, act) if len(act.Content) != 0 { - title += ctx.TrHTMLEscapeArgs("action.commit_repo", act.GetRepoAbsoluteLink(), link.Href, act.GetBranch(), act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.commit_repo", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) } else { - title += ctx.TrHTMLEscapeArgs("action.create_branch", act.GetRepoAbsoluteLink(), link.Href, act.GetBranch(), act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.create_branch", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetBranch(), act.ShortRepoPath(ctx)) } case activities_model.ActionCreateIssue: - link.Href = toIssueLink(act) - title += ctx.TrHTMLEscapeArgs("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath()) + link.Href = toIssueLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_issue", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionCreatePullRequest: - link.Href = toPullLink(act) - title += ctx.TrHTMLEscapeArgs("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath()) + link.Href = toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.create_pull_request", link.Href, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionTransferRepo: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(), act.ShortRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.transfer_repo", act.GetContent(), act.GetRepoAbsoluteLink(ctx), act.ShortRepoPath(ctx)) case activities_model.ActionPushTag: - link.Href = toTagLink(act) - title += ctx.TrHTMLEscapeArgs("action.push_tag", act.GetRepoAbsoluteLink(), link.Href, act.GetTag(), act.ShortRepoPath()) + link.Href = toTagLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.push_tag", act.GetRepoAbsoluteLink(ctx), link.Href, act.GetTag(), act.ShortRepoPath(ctx)) case activities_model.ActionCommentIssue: - issueLink := toIssueLink(act) + issueLink := toIssueLink(ctx, act) if link.Href == "#" { link.Href = issueLink } - title += ctx.TrHTMLEscapeArgs("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.comment_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionMergePullRequest: - pullLink := toPullLink(act) + pullLink := toPullLink(ctx, act) if link.Href == "#" { link.Href = pullLink } - title += ctx.TrHTMLEscapeArgs("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionAutoMergePullRequest: - pullLink := toPullLink(act) + pullLink := toPullLink(ctx, act) if link.Href == "#" { link.Href = pullLink } - title += ctx.TrHTMLEscapeArgs("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.auto_merge_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionCloseIssue: - issueLink := toIssueLink(act) + issueLink := toIssueLink(ctx, act) if link.Href == "#" { link.Href = issueLink } - title += ctx.TrHTMLEscapeArgs("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.close_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionReopenIssue: - issueLink := toIssueLink(act) + issueLink := toIssueLink(ctx, act) if link.Href == "#" { link.Href = issueLink } - title += ctx.TrHTMLEscapeArgs("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.reopen_issue", issueLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionClosePullRequest: - pullLink := toPullLink(act) + pullLink := toPullLink(ctx, act) if link.Href == "#" { link.Href = pullLink } - title += ctx.TrHTMLEscapeArgs("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.close_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionReopenPullRequest: - pullLink := toPullLink(act) + pullLink := toPullLink(ctx, act) if link.Href == "#" { link.Href = pullLink } - title += ctx.TrHTMLEscapeArgs("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.reopen_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionDeleteTag: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.delete_tag", act.GetRepoAbsoluteLink(), act.GetTag(), act.ShortRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_tag", act.GetRepoAbsoluteLink(ctx), act.GetTag(), act.ShortRepoPath(ctx)) case activities_model.ActionDeleteBranch: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.delete_branch", act.GetRepoAbsoluteLink(), html.EscapeString(act.GetBranch()), act.ShortRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.delete_branch", act.GetRepoAbsoluteLink(ctx), html.EscapeString(act.GetBranch()), act.ShortRepoPath(ctx)) case activities_model.ActionMirrorSyncPush: - srcLink := toSrcLink(act) + srcLink := toSrcLink(ctx, act) if link.Href == "#" { link.Href = srcLink } - title += ctx.TrHTMLEscapeArgs("action.mirror_sync_push", act.GetRepoAbsoluteLink(), srcLink, act.GetBranch(), act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.mirror_sync_push", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) case activities_model.ActionMirrorSyncCreate: - srcLink := toSrcLink(act) + srcLink := toSrcLink(ctx, act) if link.Href == "#" { link.Href = srcLink } - title += ctx.TrHTMLEscapeArgs("action.mirror_sync_create", act.GetRepoAbsoluteLink(), srcLink, act.GetBranch(), act.ShortRepoPath()) + titleExtra = ctx.Locale.Tr("action.mirror_sync_create", act.GetRepoAbsoluteLink(ctx), srcLink, act.GetBranch(), act.ShortRepoPath(ctx)) case activities_model.ActionMirrorSyncDelete: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.mirror_sync_delete", act.GetRepoAbsoluteLink(), act.GetBranch(), act.ShortRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.mirror_sync_delete", act.GetRepoAbsoluteLink(ctx), act.GetBranch(), act.ShortRepoPath(ctx)) case activities_model.ActionApprovePullRequest: - pullLink := toPullLink(act) - title += ctx.TrHTMLEscapeArgs("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.approve_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionRejectPullRequest: - pullLink := toPullLink(act) - title += ctx.TrHTMLEscapeArgs("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.reject_pull_request", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionCommentPull: - pullLink := toPullLink(act) - title += ctx.TrHTMLEscapeArgs("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath()) + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.comment_pull", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx)) case activities_model.ActionPublishRelease: - releaseLink := toReleaseLink(act) + releaseLink := toReleaseLink(ctx, act) if link.Href == "#" { link.Href = releaseLink } - title += ctx.TrHTMLEscapeArgs("action.publish_release", act.GetRepoAbsoluteLink(), releaseLink, act.ShortRepoPath(), act.Content) + titleExtra = ctx.Locale.Tr("action.publish_release", act.GetRepoAbsoluteLink(ctx), releaseLink, act.ShortRepoPath(ctx), act.Content) case activities_model.ActionPullReviewDismissed: - pullLink := toPullLink(act) - title += ctx.TrHTMLEscapeArgs("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(), act.GetIssueInfos()[1]) + pullLink := toPullLink(ctx, act) + titleExtra = ctx.Locale.Tr("action.review_dismissed", pullLink, act.GetIssueInfos()[0], act.ShortRepoPath(ctx), act.GetIssueInfos()[1]) case activities_model.ActionStarRepo: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.starred_repo", act.GetRepoAbsoluteLink(), act.GetRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.starred_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) case activities_model.ActionWatchRepo: - link.Href = act.GetRepoAbsoluteLink() - title += ctx.TrHTMLEscapeArgs("action.watched_repo", act.GetRepoAbsoluteLink(), act.GetRepoPath()) + link.Href = act.GetRepoAbsoluteLink(ctx) + titleExtra = ctx.Locale.Tr("action.watched_repo", act.GetRepoAbsoluteLink(ctx), act.GetRepoPath(ctx)) default: return nil, fmt.Errorf("unknown action type: %v", act.OpType) } @@ -199,57 +207,57 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio switch act.OpType { case activities_model.ActionCommitRepo, activities_model.ActionMirrorSyncPush: push := templates.ActionContent2Commits(act) - repoLink := act.GetRepoAbsoluteLink() for _, commit := range push.Commits { if len(desc) != 0 { desc += "\n\n" } desc += fmt.Sprintf("%s\n%s", - html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(), commit.Sha1)), + html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), commit.Sha1)), commit.Sha1, - templates.RenderCommitMessage(ctx, commit.Message, repoLink, nil), + templates.RenderCommitMessage(ctx, commit.Message, nil), ) } if push.Len > 1 { link = &feeds.Link{Href: fmt.Sprintf("%s/%s", setting.AppSubURL, push.CompareURL)} } else if push.Len == 1 { - link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(), push.Commits[0].Sha1)} + link = &feeds.Link{Href: fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), push.Commits[0].Sha1)} } case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: desc = strings.Join(act.GetIssueInfos(), "#") content = renderMarkdown(ctx, act, act.GetIssueContent(ctx)) case activities_model.ActionCommentIssue, activities_model.ActionApprovePullRequest, activities_model.ActionRejectPullRequest, activities_model.ActionCommentPull: - desc = act.GetIssueTitle() + desc = act.GetIssueTitle(ctx) comment := act.GetIssueInfos()[1] if len(comment) != 0 { - desc += "\n\n" + renderMarkdown(ctx, act, comment) + desc += "\n\n" + string(renderMarkdown(ctx, act, comment)) } case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: desc = act.GetIssueInfos()[1] case activities_model.ActionCloseIssue, activities_model.ActionReopenIssue, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest: - desc = act.GetIssueTitle() + desc = act.GetIssueTitle(ctx) case activities_model.ActionPullReviewDismissed: - desc = ctx.Tr("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2] + desc = ctx.Locale.TrString("action.review_dismissed_reason") + "\n\n" + act.GetIssueInfos()[2] } } if len(content) == 0 { - content = desc + content = templates.SanitizeHTML(desc) } items = append(items, &feeds.Item{ - Title: title, + Title: template.HTMLEscapeString(title) + string(titleExtra), Link: link, Description: desc, + IsPermaLink: "false", Author: &feeds.Author{ Name: act.ActUser.DisplayName(), Email: act.ActUser.GetEmail(), }, Id: fmt.Sprintf("%v: %v", strconv.FormatInt(act.ID, 10), link.Href), Created: act.CreatedUnix.AsTime(), - Content: content, + Content: string(content), }) } return items, err @@ -278,7 +286,8 @@ func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release, i return nil, err } - var title, content string + var title string + var content template.HTML if rel.IsTag { title = rel.TagName @@ -288,11 +297,12 @@ func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release, i link := &feeds.Link{Href: rel.HTMLURL()} content, err = markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: rel.Repo.Link(), - Metas: rel.Repo.ComposeMetas(), + Ctx: ctx, + Links: markup.Links{ + Base: rel.Repo.Link(), + }, + Metas: rel.Repo.ComposeMetas(ctx), }, rel.Note) - if err != nil { return nil, err } @@ -306,7 +316,7 @@ func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release, i Email: rel.Publisher.GetEmail(), }, Id: fmt.Sprintf("%v: %v", strconv.FormatInt(rel.ID, 10), link.Href), - Content: content, + Content: string(content), }) } diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go index 56a9c54ddcb..1ab768ff27f 100644 --- a/routers/web/feed/file.go +++ b/routers/web/feed/file.go @@ -9,9 +9,9 @@ import ( "time" "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go index ce86727e248..08cbcd9e12b 100644 --- a/routers/web/feed/profile.go +++ b/routers/web/feed/profile.go @@ -7,9 +7,9 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) @@ -42,8 +42,10 @@ func showUserFeed(ctx *context.Context, formatType string) { } ctxUserDescription, err := markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: ctx.ContextUser.HTMLURL(), + Ctx: ctx, + Links: markup.Links{ + Base: ctx.ContextUser.HTMLURL(), + }, Metas: map[string]string{ "user": ctx.ContextUser.GetDisplayName(), }, @@ -54,9 +56,9 @@ func showUserFeed(ctx *context.Context, formatType string) { } feed := &feeds.Feed{ - Title: ctx.Tr("home.feed_of", ctx.ContextUser.DisplayName()), + Title: ctx.Locale.TrString("home.feed_of", ctx.ContextUser.DisplayName()), Link: &feeds.Link{Href: ctx.ContextUser.HTMLURL()}, - Description: ctxUserDescription, + Description: string(ctxUserDescription), Created: time.Now(), } diff --git a/routers/web/feed/release.go b/routers/web/feed/release.go index fbfa11c63ec..273f47e3b46 100644 --- a/routers/web/feed/release.go +++ b/routers/web/feed/release.go @@ -6,16 +6,18 @@ package feed import ( "time" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) // shows tags and/or releases on the repo as RSS / Atom feed func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) { - releases, err := repo_model.GetReleasesByRepoID(ctx, ctx.Repo.Repository.ID, repo_model.FindReleasesOptions{ + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ IncludeTags: !isReleasesOnly, + RepoID: ctx.Repo.Repository.ID, }) if err != nil { ctx.ServerError("GetReleasesByRepoID", err) @@ -26,10 +28,10 @@ func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleas var link *feeds.Link if isReleasesOnly { - title = ctx.Tr("repo.release.releases_for", repo.FullName()) + title = ctx.Locale.TrString("repo.release.releases_for", repo.FullName()) link = &feeds.Link{Href: repo.HTMLURL() + "/release"} } else { - title = ctx.Tr("repo.release.tags_for", repo.FullName()) + title = ctx.Locale.TrString("repo.release.tags_for", repo.FullName()) link = &feeds.Link{Href: repo.HTMLURL() + "/tags"} } diff --git a/routers/web/feed/render.go b/routers/web/feed/render.go index 8931dae8cce..a41808c24ac 100644 --- a/routers/web/feed/render.go +++ b/routers/web/feed/render.go @@ -4,7 +4,7 @@ package feed import ( - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) // RenderBranchFeed render format for branch or file diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go index 5fcad267791..bfcc3a37d6a 100644 --- a/routers/web/feed/repo.go +++ b/routers/web/feed/repo.go @@ -8,7 +8,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "github.com/gorilla/feeds" ) @@ -27,7 +27,7 @@ func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType } feed := &feeds.Feed{ - Title: ctx.Tr("home.feed_of", repo.FullName()), + Title: ctx.Locale.TrString("home.feed_of", repo.FullName()), Link: &feeds.Link{Href: repo.HTMLURL()}, Description: repo.Description, Created: time.Now(), diff --git a/routers/web/githttp.go b/routers/web/githttp.go new file mode 100644 index 00000000000..5f1dedce763 --- /dev/null +++ b/routers/web/githttp.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" +) + +func requireSignIn(ctx *context.Context) { + if !setting.Service.RequireSignInView { + return + } + + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) + ctx.Error(http.StatusUnauthorized) + } +} + +func gitHTTPRouters(m *web.Route) { + m.Group("", func() { + m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack) + m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack) + m.Methods("GET,OPTIONS", "/info/refs", repo.GetInfoRefs) + m.Methods("GET,OPTIONS", "/HEAD", repo.GetTextFile("HEAD")) + m.Methods("GET,OPTIONS", "/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) + m.Methods("GET,OPTIONS", "/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) + m.Methods("GET,OPTIONS", "/objects/info/packs", repo.GetInfoPacks) + m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile("")) + m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile) + m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile) + }, ignSignInAndCsrf, requireSignIn, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context.UserAssignmentWeb()) +} diff --git a/routers/web/goget.go b/routers/web/goget.go index c5b8b6cbc01..8d5612ebfe9 100644 --- a/routers/web/goget.go +++ b/routers/web/goget.go @@ -12,9 +12,9 @@ import ( "strings" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) func goGet(ctx *context.Context) { diff --git a/routers/web/healthcheck/check.go b/routers/web/healthcheck/check.go index ecb73a928fd..85f47613f0d 100644 --- a/routers/web/healthcheck/check.go +++ b/routers/web/healthcheck/check.go @@ -121,10 +121,6 @@ func checkDatabase(ctx context.Context, checks checks) status { // cache checks gitea cache status func checkCache(checks checks) status { - if !setting.CacheService.Enabled { - return pass - } - st := componentStatus{} if err := cache.GetCache().Ping(); err != nil { st.Status = fail diff --git a/routers/web/home.go b/routers/web/home.go index ab3fbde2c9a..d4be0931e85 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -12,15 +12,15 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sitemap" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/routers/web/auth" "code.gitea.io/gitea/routers/web/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -54,8 +54,7 @@ func Home(ctx *context.Context) { } // Check auto-login. - uname := ctx.GetSiteCookie(setting.CookieUserName) - if len(uname) != 0 { + if ctx.GetSiteCookie(setting.CookieRememberName) != "" { ctx.Redirect(setting.AppSubURL + "/user/login") return } @@ -72,7 +71,7 @@ func HomeSitemap(ctx *context.Context) { _, cnt, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{PageSize: 1}, - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic}, }) if err != nil { diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go index c91da9a7f10..2dbbd6fc097 100644 --- a/routers/web/misc/markup.go +++ b/routers/web/misc/markup.go @@ -5,10 +5,10 @@ package misc import ( - "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" ) // Markup render markup document to HTML diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 54c93763f6a..ac5496ce91a 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -15,7 +15,7 @@ import ( ) func SSHInfo(rw http.ResponseWriter, req *http.Request) { - if !git.SupportProcReceive { + if !git.DefaultFeatures.SupportProcReceive { rw.WriteHeader(http.StatusNotFound) return } diff --git a/routers/web/misc/swagger.go b/routers/web/misc/swagger.go index 72c09a37801..5fddfa8885f 100644 --- a/routers/web/misc/swagger.go +++ b/routers/web/misc/swagger.go @@ -7,7 +7,7 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) // tplSwagger swagger page template diff --git a/routers/web/nodeinfo.go b/routers/web/nodeinfo.go index 01b71e7086b..f1cc7bf530f 100644 --- a/routers/web/nodeinfo.go +++ b/routers/web/nodeinfo.go @@ -7,8 +7,8 @@ import ( "fmt" "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) type nodeInfoLinks struct { diff --git a/routers/web/org/block.go b/routers/web/org/block.go new file mode 100644 index 00000000000..d40458e2506 --- /dev/null +++ b/routers/web/org/block.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "org/settings/blocked_users" +) + +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("user.block.list") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsBlockedUsers"] = true + + shared_user.BlockedUsers(ctx, ctx.ContextUser) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +func BlockedUsersPost(ctx *context.Context) { + shared_user.BlockedUsersPost(ctx, ctx.ContextUser) + if ctx.Written() { + return + } + + ctx.Redirect(ctx.ContextUser.OrganisationLink() + "/settings/blocked_users") +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 15386393e9f..846b1de18ab 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -5,18 +5,21 @@ package org import ( "net/http" + "path" "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -42,19 +45,6 @@ func Home(ctx *context.Context) { ctx.Data["PageIsUserProfile"] = true ctx.Data["Title"] = org.DisplayName() - if len(org.Description) != 0 { - desc, err := markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: ctx.Repo.RepoLink, - Metas: map[string]string{"mode": "document"}, - GitRepo: ctx.Repo.GitRepo, - }, org.Description) - if err != nil { - ctx.ServerError("RenderString", err) - return - } - ctx.Data["RenderedDescription"] = desc - } var orderBy db.SearchOrderBy ctx.Data["SortType"] = ctx.FormString("sort") @@ -95,6 +85,21 @@ func Home(ctx *context.Context) { page = 1 } + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + var ( repos []*repo_model.Repository count int64 @@ -112,6 +117,11 @@ func Home(ctx *context.Context) { Actor: ctx.Doer, Language: language, IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -129,18 +139,12 @@ func Home(ctx *context.Context) { return } - var isFollowing bool - if ctx.Doer != nil { - isFollowing = user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID) - } - ctx.Data["Repos"] = repos ctx.Data["Total"] = count ctx.Data["Members"] = members ctx.Data["Teams"] = ctx.Org.Teams ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["PageIsViewRepositories"] = true - ctx.Data["IsFollowing"] = isFollowing err = shared_user.LoadHeaderCount(ctx) if err != nil { @@ -150,10 +154,40 @@ func Home(ctx *context.Context) { pager := context.NewPagination(int(count), setting.UI.User.RepoPagingNum, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "language", "Language") + pager.AddParamString("language", language) ctx.Data["Page"] = pager ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) + defer profileClose() + prepareOrgProfileReadme(ctx, profileGitRepo, profileDbRepo, profileReadmeBlob) + ctx.HTML(http.StatusOK, tplOrgHome) } + +func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repository, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { + if profileGitRepo == nil || profileReadme == nil { + return + } + + if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("failed to GetBlobContent: %v", err) + } else { + if profileContent, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + GitRepo: profileGitRepo, + Links: markup.Links{ + // Pass repo link to markdown render for the full link of media elements. + // The profile of default branch would be shown. + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, + }, bytes); err != nil { + log.Error("failed to RenderString: %v", err) + } else { + ctx.Data["ProfileReadme"] = profileContent + } + } +} diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go index 41323a3601e..92237d6e884 100644 --- a/routers/web/org/main_test.go +++ b/routers/web/org/main_test.go @@ -4,14 +4,11 @@ package org_test import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 34cff05ea0b..63ac57cf0dc 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -9,11 +9,12 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -38,7 +39,7 @@ func Members(ctx *context.Context) { } if ctx.Doer != nil { - isMember, err := ctx.Org.Organization.IsOrgMember(ctx.Doer.ID) + isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "IsOrgMember") return @@ -47,7 +48,7 @@ func Members(ctx *context.Context) { } ctx.Data["PublicOnly"] = opts.PublicOnly - total, err := organization.CountOrgMembers(opts) + total, err := organization.CountOrgMembers(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "CountOrgMembers") return @@ -70,7 +71,7 @@ func Members(ctx *context.Context) { ctx.Data["Page"] = pager ctx.Data["Members"] = members ctx.Data["MembersIsPublicMember"] = membersIsPublic - ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(members, org.ID) + ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) ctx.HTML(http.StatusOK, tplMembers) @@ -78,40 +79,43 @@ func Members(ctx *context.Context) { // MembersAction response for operation to a member of organization func MembersAction(ctx *context.Context) { - uid := ctx.FormInt64("uid") - if uid == 0 { + member, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) + if err != nil { + log.Error("GetUserByID: %v", err) + } + if member == nil { ctx.Redirect(ctx.Org.OrgLink + "/members") return } org := ctx.Org.Organization - var err error + switch ctx.Params(":action") { case "private": - if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = organization.ChangeOrgUserStatus(org.ID, uid, false) + err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, false) case "public": - if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = organization.ChangeOrgUserStatus(org.ID, uid, true) + err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, true) case "remove": if !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = models.RemoveOrgUser(org.ID, uid) + err = models.RemoveOrgUser(ctx, org, member) if organization.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) ctx.JSONRedirect(ctx.Org.OrgLink + "/members") return } case "leave": - err = models.RemoveOrgUser(org.ID, ctx.Doer.ID) + err = models.RemoveOrgUser(ctx, org, ctx.Doer) if err == nil { ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName())) ctx.JSON(http.StatusOK, map[string]any{ diff --git a/routers/web/org/org.go b/routers/web/org/org.go index f67e7edb4c7..f94dd16eaef 100644 --- a/routers/web/org/org.go +++ b/routers/web/org/org.go @@ -12,10 +12,10 @@ import ( "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -29,7 +29,7 @@ func Create(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("new_org") ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode if !ctx.Doer.CanCreateOrganization() { - ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed"))) + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) return } ctx.HTML(http.StatusOK, tplCreateOrg) @@ -41,7 +41,7 @@ func CreatePost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("new_org") if !ctx.Doer.CanCreateOrganization() { - ctx.ServerError("Not allowed", errors.New(ctx.Tr("org.form.create_org_not_allowed"))) + ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) return } @@ -58,7 +58,7 @@ func CreatePost(ctx *context.Context) { RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, } - if err := organization.CreateOrganization(org, ctx.Doer); err != nil { + if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil { ctx.Data["Err_OrgName"] = true switch { case user_model.IsErrUserAlreadyExist(err): diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go index 2c7725e38da..02eae8052ea 100644 --- a/routers/web/org/org_labels.go +++ b/routers/web/org/org_labels.go @@ -8,10 +8,10 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/label" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -76,7 +76,7 @@ func UpdateLabel(ctx *context.Context) { l.Description = form.Description l.Color = form.Color l.SetArchived(form.IsArchived) - if err := issues_model.UpdateLabel(l); err != nil { + if err := issues_model.UpdateLabel(ctx, l); err != nil { ctx.ServerError("UpdateLabel", err) return } @@ -85,7 +85,7 @@ func UpdateLabel(ctx *context.Context) { // DeleteLabel delete a label func DeleteLabel(ctx *context.Context) { - if err := issues_model.DeleteLabel(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteLabel: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 4032162b5cf..596a370d2e5 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -11,17 +11,19 @@ import ( "strconv" "strings" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -59,10 +61,13 @@ func Projects(ctx *context.Context) { } else { projectType = project_model.TypeIndividual } - projects, total, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, OwnerID: ctx.ContextUser.ID, - Page: page, - IsClosed: util.OptionalBoolOf(isShowClosed), + IsClosed: optional.Some(isShowClosed), OrderBy: project_model.GetSearchOrderByBySortType(sortType), Type: projectType, Title: keyword, @@ -72,9 +77,9 @@ func Projects(ctx *context.Context) { return } - opTotal, err := project_model.CountProjects(ctx, project_model.SearchOptions{ + opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ OwnerID: ctx.ContextUser.ID, - IsClosed: util.OptionalBoolOf(!isShowClosed), + IsClosed: optional.Some(!isShowClosed), Type: projectType, }) if err != nil { @@ -100,7 +105,7 @@ func Projects(ctx *context.Context) { } for _, project := range projects { - project.RenderedContent = project.Description + project.RenderedContent = templates.SanitizeHTML(project.Description) // FIXME: is it right? why not render? } err = shared_user.LoadHeaderCount(ctx) @@ -115,7 +120,7 @@ func Projects(ctx *context.Context) { } pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) - pager.AddParam(ctx, "state", "State") + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) ctx.Data["Page"] = pager ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) @@ -179,7 +184,7 @@ func NewProjectPost(ctx *context.Context) { newProject.Type = project_model.TypeIndividual } - if err := project_model.NewProject(&newProject); err != nil { + if err := project_model.NewProject(ctx, &newProject); err != nil { ctx.ServerError("NewProject", err) return } @@ -201,12 +206,8 @@ func ChangeProjectStatus(ctx *context.Context) { } id := ctx.ParamsInt64(":id") - if err := project_model.ChangeProjectStatusByRepoIDAndID(0, id, toClose); err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", err) - } else { - ctx.ServerError("ChangeProjectStatusByRepoIDAndID", err) - } + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { + ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) return } ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects?state=" + url.QueryEscape(ctx.Params(":action"))) @@ -216,11 +217,7 @@ func ChangeProjectStatus(ctx *context.Context) { func DeleteProject(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } if p.OwnerID != ctx.ContextUser.ID { @@ -249,11 +246,7 @@ func RenderEditProject(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } if p.OwnerID != ctx.ContextUser.ID { @@ -298,11 +291,7 @@ func EditProjectPost(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, projectID) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } if p.OwnerID != ctx.ContextUser.ID { @@ -320,7 +309,7 @@ func EditProjectPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) if ctx.FormString("redirect") == "project" { - ctx.Redirect(p.Link()) + ctx.Redirect(p.Link(ctx)) } else { ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") } @@ -330,11 +319,7 @@ func EditProjectPost(ctx *context.Context) { func ViewProject(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } if project.OwnerID != ctx.ContextUser.ID { @@ -348,10 +333,6 @@ func ViewProject(ctx *context.Context) { return } - if boards[0].ID == 0 { - boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") - } - issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) if err != nil { ctx.ServerError("LoadIssuesOfBoards", err) @@ -373,17 +354,17 @@ func ViewProject(ctx *context.Context) { linkedPrsMap := make(map[int64][]*issues_model.Issue) for _, issuesList := range issuesMap { for _, issue := range issuesList { - var referencedIds []int64 + var referencedIDs []int64 for _, comment := range issue.Comments { if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIds = append(referencedIds, comment.RefIssueID) + referencedIDs = append(referencedIDs, comment.RefIssueID) } } - if len(referencedIds) > 0 { + if len(referencedIDs) > 0 { if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIds, - IsPull: util.OptionalBoolTrue, + IssueIDs: referencedIDs, + IsPull: optional.Some(true), }); err == nil { linkedPrsMap[issue.ID] = linkedPrs } @@ -391,7 +372,7 @@ func ViewProject(ctx *context.Context) { } } - project.RenderedContent = project.Description + project.RenderedContent = templates.SanitizeHTML(project.Description) // FIXME: is it right? why not render? ctx.Data["LinkedPRs"] = linkedPrsMap ctx.Data["PageIsViewProjects"] = true ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) @@ -468,7 +449,7 @@ func UpdateIssueProject(ctx *context.Context) { } } - if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil { + if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { ctx.ServerError("ChangeProjectAssign", err) return } @@ -488,11 +469,7 @@ func DeleteProjectBoard(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } @@ -515,7 +492,7 @@ func DeleteProjectBoard(ctx *context.Context) { return } - if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { ctx.ServerError("DeleteProjectBoardByID", err) return } @@ -529,15 +506,11 @@ func AddBoardToProjectPost(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } - if err := project_model.NewBoard(&project_model.Board{ + if err := project_model.NewBoard(ctx, &project_model.Board{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -561,11 +534,7 @@ func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Pr project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return nil, nil } @@ -623,22 +592,7 @@ func SetDefaultProjectBoard(ctx *context.Context) { return } - if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil { - ctx.ServerError("SetDefaultBoard", err) - return - } - - ctx.JSONOK() -} - -// UnsetDefaultProjectBoard unset default board for uncategorized issues/pulls -func UnsetDefaultProjectBoard(ctx *context.Context) { - project, _ := CheckProjectBoardChangePermissions(ctx) - if ctx.Written() { - return - } - - if err := project_model.SetDefaultBoard(project.ID, 0); err != nil { + if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { ctx.ServerError("SetDefaultBoard", err) return } @@ -657,11 +611,7 @@ func MoveIssues(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) if err != nil { - if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("ProjectNotExist", nil) - } else { - ctx.ServerError("GetProjectByID", err) - } + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) return } if project.OwnerID != ctx.ContextUser.ID { @@ -669,28 +619,15 @@ func MoveIssues(ctx *context.Context) { return } - var board *project_model.Board + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectBoard", project_model.IsErrProjectBoardNotExist, err) + return + } - if ctx.ParamsInt64(":boardID") == 0 { - board = &project_model.Board{ - ID: 0, - ProjectID: project.ID, - Title: ctx.Tr("repo.projects.type.uncategorized"), - } - } else { - board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) - if err != nil { - if project_model.IsErrProjectBoardNotExist(err) { - ctx.NotFound("ProjectBoardNotExist", nil) - } else { - ctx.ServerError("GetProjectBoard", err) - } - return - } - if board.ProjectID != project.ID { - ctx.NotFound("BoardNotInProject", nil) - return - } + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) + return } type movedIssuesForm struct { @@ -713,11 +650,7 @@ func MoveIssues(ctx *context.Context) { } movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) if err != nil { - if issues_model.IsErrIssueNotExist(err) { - ctx.NotFound("IssueNotExisting", nil) - } else { - ctx.ServerError("GetIssueByID", err) - } + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) return } @@ -738,7 +671,7 @@ func MoveIssues(ctx *context.Context) { } } - if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil { + if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { ctx.ServerError("MoveIssuesOnProjectBoard", err) return } diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go index 8053ab4cf93..f4ccfe1c066 100644 --- a/routers/web/org/projects_test.go +++ b/routers/web/org/projects_test.go @@ -7,8 +7,8 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/routers/web/org" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 0f082a70dfa..494ada4323a 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -7,7 +7,6 @@ package org import ( "net/http" "net/url" - "strings" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" @@ -15,13 +14,14 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" @@ -71,60 +71,57 @@ func SettingsPost(ctx *context.Context) { } org := ctx.Org.Organization - nameChanged := org.Name != form.Name - - // Check if organization name has been changed. - if nameChanged { - err := org_service.RenameOrganization(ctx, org, form.Name) - switch { - case user_model.IsErrUserAlreadyExist(err): - ctx.Data["OrgName"] = true - ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) - return - case db.IsErrNameReserved(err): - ctx.Data["OrgName"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) - return - case db.IsErrNamePatternNotAllowed(err): - ctx.Data["OrgName"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) - return - case err != nil: - ctx.ServerError("org_service.RenameOrganization", err) + + if org.Name != form.Name { + if err := user_service.RenameUser(ctx, org.AsUser(), form.Name); err != nil { + if user_model.IsErrUserAlreadyExist(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) + } else if db.IsErrNameReserved(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + } else if db.IsErrNamePatternNotAllowed(err) { + ctx.Data["Err_Name"] = true + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + } else { + ctx.ServerError("RenameUser", err) + } return } - // reset ctx.org.OrgLink with new name - ctx.Org.OrgLink = setting.AppSubURL + "/org/" + url.PathEscape(form.Name) - log.Trace("Organization name changed: %s -> %s", org.Name, form.Name) + ctx.Org.OrgLink = setting.AppSubURL + "/org/" + url.PathEscape(org.Name) } - // In case it's just a case change. - org.Name = form.Name - org.LowerName = strings.ToLower(form.Name) + if form.Email != "" { + if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil { + ctx.Data["Err_Email"] = true + ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form) + return + } + } + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Description: optional.Some(form.Description), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.Some(form.Visibility), + RepoAdminChangeTeamAccess: optional.Some(form.RepoAdminChangeTeamAccess), + } if ctx.Doer.IsAdmin { - org.MaxRepoCreation = form.MaxRepoCreation + opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation) } - org.FullName = form.FullName - org.Email = form.Email - org.Description = form.Description - org.Website = form.Website - org.Location = form.Location - org.RepoAdminChangeTeamAccess = form.RepoAdminChangeTeamAccess - - visibilityChanged := form.Visibility != org.Visibility - org.Visibility = form.Visibility + visibilityChanged := org.Visibility != form.Visibility - if err := user_model.UpdateUser(ctx, org.AsUser(), false); err != nil { + if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil { ctx.ServerError("UpdateUser", err) return } // update forks visibility if visibilityChanged { - repos, _, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: org.AsUser(), Private: true, ListOptions: db.ListOptions{Page: 1, PageSize: org.NumRepos}, }) if err != nil { @@ -160,7 +157,7 @@ func SettingsAvatar(ctx *context.Context) { // SettingsDeleteAvatar response for delete avatar on settings page func SettingsDeleteAvatar(ctx *context.Context) { - if err := user_service.DeleteAvatar(ctx.Org.Organization.AsUser()); err != nil { + if err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()); err != nil { ctx.Flash.Error(err.Error()) } @@ -180,7 +177,7 @@ func SettingsDelete(ctx *context.Context) { return } - if err := org_service.DeleteOrganization(ctx.Org.Organization); err != nil { + if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { if models.IsErrUserOwnRepos(err) { ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") @@ -215,7 +212,7 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") - ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID}) + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID}) if err != nil { ctx.ServerError("ListWebhooksByOpts", err) return @@ -233,7 +230,7 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByOwnerID(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go index c3c771036aa..fe05709237d 100644 --- a/routers/web/org/setting/runners.go +++ b/routers/web/org/setting/runners.go @@ -4,7 +4,7 @@ package setting import ( - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) func RedirectToDefaultSetting(ctx *context.Context) { diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go index 0045bce4c93..7f855795d36 100644 --- a/routers/web/org/setting_oauth2.go +++ b/routers/web/org/setting_oauth2.go @@ -8,11 +8,12 @@ import ( "net/http" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/context" ) const ( @@ -35,7 +36,9 @@ func Applications(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsApplications"] = true - apps, err := auth.GetOAuth2ApplicationsByUserID(ctx, ctx.Org.Organization.ID) + apps, err := db.Find[auth.OAuth2Application](ctx, auth.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Org.Organization.ID, + }) if err != nil { ctx.ServerError("GetOAuth2ApplicationsByUserID", err) return diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go index 796829d34ef..af9836e42c2 100644 --- a/routers/web/org/setting_packages.go +++ b/routers/web/org/setting_packages.go @@ -8,10 +8,10 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" shared "code.gitea.io/gitea/routers/web/shared/packages" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index dfb87b28f89..144d9b1b43d 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -5,6 +5,7 @@ package org import ( + "errors" "fmt" "net/http" "net/url" @@ -20,12 +21,11 @@ import ( unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers/utils" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" org_service "code.gitea.io/gitea/services/org" @@ -78,9 +78,9 @@ func TeamsAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) case "leave": - err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -101,13 +101,13 @@ func TeamsAction(ctx *context.Context) { return } - uid := ctx.FormInt64("uid") - if uid == 0 { + user, _ := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) + if user == nil { ctx.Redirect(ctx.Org.OrgLink + "/teams") return } - err = models.RemoveTeamMember(ctx, ctx.Org.Team, uid) + err = models.RemoveTeamMember(ctx, ctx.Org.Team, user) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -127,7 +127,7 @@ func TeamsAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname"))) + uname := strings.ToLower(ctx.FormString("uname")) var u *user_model.User u, err = user_model.GetUserByName(ctx, uname) if err != nil { @@ -159,10 +159,10 @@ func TeamsAction(ctx *context.Context) { return } - if ctx.Org.Team.IsMember(u.ID) { + if ctx.Org.Team.IsMember(ctx, u.ID) { ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) } else { - err = models.AddTeamMember(ctx, ctx.Org.Team, u.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, u) } page = "team" @@ -190,6 +190,8 @@ func TeamsAction(ctx *context.Context) { if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("org.teams.members.blocked_user")) } else { log.Error("Action(%s): %v", ctx.Params(":action"), err) ctx.JSON(http.StatusOK, map[string]any{ @@ -237,7 +239,7 @@ func TeamsRepoAction(ctx *context.Context) { case "add": repoName := path.Base(ctx.FormString("repo_name")) var repo *repo_model.Repository - repo, err = repo_model.GetRepositoryByName(ctx.Org.Organization.ID, repoName) + repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName) if err != nil { if repo_model.IsErrRepoNotExist(err) { ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo")) @@ -276,6 +278,10 @@ func NewTeam(ctx *context.Context) { ctx.Data["PageIsOrgTeamsNew"] = true ctx.Data["Team"] = &org_model.Team{} ctx.Data["Units"] = unit_model.Units + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } ctx.HTML(http.StatusOK, tplTeamNew) } @@ -428,7 +434,7 @@ func SearchTeam(ctx *context.Context) { ListOptions: listOptions, } - teams, maxResults, err := org_model.SearchTeam(opts) + teams, maxResults, err := org_model.SearchTeam(ctx, opts) if err != nil { log.Error("SearchTeam failed: %v", err) ctx.JSON(http.StatusInternalServerError, map[string]any{ @@ -463,6 +469,10 @@ func EditTeam(ctx *context.Context) { ctx.ServerError("LoadUnits", err) return } + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } ctx.Data["Team"] = ctx.Org.Team ctx.Data["Units"] = unit_model.Units ctx.HTML(http.StatusOK, tplTeamNew) @@ -583,7 +593,7 @@ func TeamInvitePost(ctx *context.Context) { return } - if err := models.AddTeamMember(ctx, team, ctx.Doer.ID); err != nil { + if err := models.AddTeamMember(ctx, team, ctx.Doer); err != nil { ctx.ServerError("AddTeamMember", err) return } diff --git a/routers/web/passkey.go b/routers/web/passkey.go new file mode 100644 index 00000000000..0d10a69dfe6 --- /dev/null +++ b/routers/web/passkey.go @@ -0,0 +1,24 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" +) + +type passkeyEndpointsType struct { + Enroll string `json:"enroll"` + Manage string `json:"manage"` +} + +func passkeyEndpoints(ctx *context.Context) { + url := setting.AppURL + "user/settings/security" + ctx.JSON(http.StatusOK, passkeyEndpointsType{ + Enroll: url, + Manage: url, + }) +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 6284d21463f..6059ad1414b 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -15,10 +15,11 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "github.com/nektos/act/pkg/model" @@ -60,26 +61,26 @@ func List(ctx *context.Context) { var workflows []Workflow if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("IsEmpty", err) return } else if !empty { commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetBranchCommit", err) return } entries, err := actions.ListWorkflows(commit) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("ListWorkflows", err) return } // Get all runner labels - opts := actions_model.FindRunnerOptions{ + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), WithAvailable: true, - } - runners, err := actions_model.FindRunners(ctx, opts) + }) if err != nil { ctx.ServerError("FindRunners", err) return @@ -94,17 +95,22 @@ func List(ctx *context.Context) { workflow := Workflow{Entry: *entry} content, err := actions.GetContentFromEntry(entry) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetContentFromEntry", err) return } wf, err := model.ReadWorkflow(bytes.NewReader(content)) if err != nil { - workflow.ErrMsg = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", err.Error()) + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) workflows = append(workflows, workflow) continue } - // Check whether have matching runner + // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. + hasJobWithoutNeeds := false + // Check whether have matching runner and a job without "needs" for _, j := range wf.Jobs { + if !hasJobWithoutNeeds && len(j.Needs()) == 0 { + hasJobWithoutNeeds = true + } runsOnList := j.RunsOn() for _, ro := range runsOnList { if strings.Contains(ro, "${{") { @@ -114,7 +120,7 @@ func List(ctx *context.Context) { continue } if !allRunnerLabels.Contains(ro) { - workflow.ErrMsg = ctx.Locale.Tr("actions.runs.no_matching_runner_helper", ro) + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) break } } @@ -122,6 +128,9 @@ func List(ctx *context.Context) { break } } + if !hasJobWithoutNeeds { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") + } workflows = append(workflows, workflow) } } @@ -169,9 +178,9 @@ func List(ctx *context.Context) { opts.Status = []actions_model.Status{actions_model.Status(status)} } - runs, total, err := actions_model.FindRuns(ctx, opts) + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("FindAndCount", err) return } @@ -179,8 +188,8 @@ func List(ctx *context.Context) { run.Repo = ctx.Repo.Repository } - if err := runs.LoadTriggerUser(ctx); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) return } @@ -188,7 +197,7 @@ func List(ctx *context.Context) { actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetActors", err) return } ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors) @@ -201,6 +210,7 @@ func List(ctx *context.Context) { pager.AddParamString("actor", fmt.Sprint(actorID)) pager.AddParamString("status", fmt.Sprint(status)) ctx.Data["Page"] = pager + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 ctx.HTML(http.StatusOK, tplListActions) } diff --git a/routers/web/repo/actions/badge.go b/routers/web/repo/actions/badge.go new file mode 100644 index 00000000000..6fa951826c3 --- /dev/null +++ b/routers/web/repo/actions/badge.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/badge" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +func GetWorkflowBadge(ctx *context.Context) { + workflowFile := ctx.Params("workflow_name") + branch := ctx.Req.URL.Query().Get("branch") + if branch == "" { + branch = ctx.Repo.Repository.DefaultBranch + } + branchRef := fmt.Sprintf("refs/heads/%s", branch) + event := ctx.Req.URL.Query().Get("event") + + badge, err := getWorkflowBadge(ctx, workflowFile, branchRef, event) + if err != nil { + ctx.ServerError("GetWorkflowBadge", err) + return + } + + ctx.Data["Badge"] = badge + ctx.RespHeader().Set("Content-Type", "image/svg+xml") + ctx.HTML(http.StatusOK, "shared/actions/runner_badge") +} + +func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) { + extension := filepath.Ext(workflowFile) + workflowName := strings.TrimSuffix(workflowFile, extension) + + run, err := actions_model.GetWorkflowLatestRun(ctx, ctx.Repo.Repository.ID, workflowFile, branchName, event) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return badge.GenerateBadge(workflowName, "no status", badge.DefaultColor), nil + } + return badge.Badge{}, err + } + + color, ok := badge.StatusColorMap[run.Status] + if !ok { + return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil + } + return badge.GenerateBadge(workflowName, run.Status.String(), color), nil +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index a9c2858303a..41989589beb 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -21,12 +22,13 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" - context_module "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" actions_service "code.gitea.io/gitea/services/actions" + context_module "code.gitea.io/gitea/services/context" "xorm.io/builder" ) @@ -57,15 +59,16 @@ type ViewRequest struct { type ViewResponse struct { State struct { Run struct { - Link string `json:"link"` - Title string `json:"title"` - Status string `json:"status"` - CanCancel bool `json:"canCancel"` - CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve - CanRerun bool `json:"canRerun"` - Done bool `json:"done"` - Jobs []*ViewJob `json:"jobs"` - Commit ViewCommit `json:"commit"` + Link string `json:"link"` + Title string `json:"title"` + Status string `json:"status"` + CanCancel bool `json:"canCancel"` + CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve + CanRerun bool `json:"canRerun"` + CanDeleteArtifact bool `json:"canDeleteArtifact"` + Done bool `json:"done"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` } `json:"run"` CurrentJob struct { Title string `json:"title"` @@ -146,6 +149,7 @@ func ViewPost(ctx *context_module.Context) { resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.Done = run.Status.IsDone() resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json resp.State.Run.Status = run.Status.String() @@ -168,8 +172,8 @@ func ViewPost(ctx *context_module.Context) { Link: run.RefLink(), } resp.State.Run.Commit = ViewCommit{ - LocaleCommit: ctx.Tr("actions.runs.commit"), - LocalePushedBy: ctx.Tr("actions.runs.pushed_by"), + LocaleCommit: ctx.Locale.TrString("actions.runs.commit"), + LocalePushedBy: ctx.Locale.TrString("actions.runs.pushed_by"), ShortSha: base.ShortSha(run.CommitSHA), Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA), Pusher: pusher, @@ -194,7 +198,7 @@ func ViewPost(ctx *context_module.Context) { resp.State.CurrentJob.Title = current.Name resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) if run.NeedApproval { - resp.State.CurrentJob.Detail = ctx.Locale.Tr("actions.need_approval_desc") + resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") } resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json @@ -260,10 +264,14 @@ func ViewPost(ctx *context_module.Context) { } // Rerun will rerun jobs in the given run -// jobIndex = 0 means rerun all jobs +// If jobIndexStr is a blank string, it means rerun all jobs func Rerun(ctx *context_module.Context) { runIndex := ctx.ParamsInt64("run") - jobIndex := ctx.ParamsInt64("job") + jobIndexStr := ctx.Params("job") + var jobIndex int64 + if jobIndexStr != "" { + jobIndex, _ = strconv.ParseInt(jobIndexStr, 10, 64) + } run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { @@ -279,17 +287,41 @@ func Rerun(ctx *context_module.Context) { return } + // reset run's start and stop time when it is done + if run.Status.IsDone() { + run.PreviousDuration = run.Duration() + run.Started = 0 + run.Stopped = 0 + if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + job, jobs := getRunJobs(ctx, runIndex, jobIndex) if ctx.Written() { return } - if jobIndex != 0 { - jobs = []*actions_model.ActionRunJob{job} + if jobIndexStr == "" { // rerun all jobs + for _, j := range jobs { + // if the job has needs, it should be set to "blocked" status to wait for other jobs + shouldBlock := len(j.Needs) > 0 + if err := rerunJob(ctx, j, shouldBlock); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + ctx.JSON(http.StatusOK, struct{}{}) + return } - for _, j := range jobs { - if err := rerunJob(ctx, j); err != nil { + rerunJobs := actions_service.GetAllRerunJobs(job, jobs) + + for _, j := range rerunJobs { + // jobs other than the specified one should be set to "blocked" status + shouldBlock := j.JobID != job.JobID + if err := rerunJob(ctx, j, shouldBlock); err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return } @@ -298,7 +330,7 @@ func Rerun(ctx *context_module.Context) { ctx.JSON(http.StatusOK, struct{}{}) } -func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob) error { +func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { status := job.Status if !status.IsDone() { return nil @@ -306,6 +338,9 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob) erro job.TaskID = 0 job.Status = actions_model.StatusWaiting + if shouldBlock { + job.Status = actions_model.StatusBlocked + } job.Started = 0 job.Stopped = 0 @@ -512,7 +547,7 @@ func ArtifactsView(ctx *context_module.Context) { } for _, art := range artifacts { status := "completed" - if art.Status == int64(actions_model.ArtifactStatusExpired) { + if art.Status == actions_model.ArtifactStatusExpired { status = "expired" } artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ @@ -524,6 +559,29 @@ func ArtifactsView(ctx *context_module.Context) { ctx.JSON(http.StatusOK, artifactsResponse) } +func ArtifactsDeleteView(ctx *context_module.Context) { + if !ctx.Repo.CanWrite(unit.TypeActions) { + ctx.Error(http.StatusForbidden, "no permission") + return + } + + runIndex := ctx.ParamsInt64("run") + artifactName := ctx.Params("artifact_name") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, struct{}{}) +} + func ArtifactsDownloadView(ctx *context_module.Context) { runIndex := ctx.ParamsInt64("run") artifactName := ctx.Params("artifact_name") @@ -538,7 +596,10 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } - artifacts, err := actions_model.ListArtifactsByRunIDAndName(ctx, run.ID, artifactName) + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return @@ -548,8 +609,38 @@ func ArtifactsDownloadView(ctx *context_module.Context) { return } + // if artifacts status is not uploaded-confirmed, treat it as not found + for _, art := range artifacts { + if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) { + ctx.Error(http.StatusNotFound, "artifact not found") + return + } + } + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend + // The v4 backend enshures ContentEncoding is set to "application/zip", which is not the case for the old backend + if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" { + art := artifacts[0] + if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath) + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + _, _ = io.Copy(ctx.Resp, f) + return + } + + // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend + // Those need to be zipped for download writer := zip.NewWriter(ctx.Resp) defer writer.Close() for _, art := range artifacts { @@ -608,7 +699,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { cfg.DisableWorkflow(workflow) } - if err := repo_model.UpdateRepoUnit(cfgUnit); err != nil { + if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil { ctx.ServerError("UpdateRepoUnit", err) return } diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go index 3d030edaca0..6f6641cc65e 100644 --- a/routers/web/repo/activity.go +++ b/routers/web/repo/activity.go @@ -10,7 +10,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) const ( @@ -22,6 +22,8 @@ func Activity(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.activity") ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsPulse"] = true + ctx.Data["Period"] = ctx.Params("period") timeUntil := time.Now() diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index 7b7fa9e994c..f0c5622aec7 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -9,15 +9,15 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" repo_service "code.gitea.io/gitea/services/repository" ) @@ -45,7 +45,7 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { } defer file.Close() - attach, err := attachment.UploadAttachment(file, allowedTypes, header.Size, &repo_model.Attachment{ + attach, err := attachment.UploadAttachment(ctx, file, allowedTypes, header.Size, &repo_model.Attachment{ Name: header.Filename, UploaderID: ctx.Doer.ID, RepoID: repoID, diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index b1cb42297c1..1887e4d95da 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -8,18 +8,20 @@ import ( gotemplate "html/template" "net/http" "net/url" + "strconv" "strings" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" ) type blameRow struct { @@ -45,10 +47,6 @@ func RefBlame(ctx *context.Context) { return } - userName := ctx.Repo.Owner.Name - repoName := ctx.Repo.Repository.Name - commitID := ctx.Repo.CommitID - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() treeLink := branchLink rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() @@ -74,7 +72,7 @@ func RefBlame(ctx *context.Context) { // Get current entry user currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return } @@ -90,9 +88,16 @@ func RefBlame(ctx *context.Context) { ctx.Data["IsBlame"] = true - ctx.Data["FileSize"] = blob.Size() + fileSize := blob.Size() + ctx.Data["FileSize"] = fileSize ctx.Data["FileName"] = blob.Name() + if fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + ctx.HTML(http.StatusOK, tplRepoHome) + return + } + ctx.Data["NumLines"], err = blob.GetBlobLineCount() ctx.Data["NumLinesSet"] = true @@ -101,26 +106,16 @@ func RefBlame(ctx *context.Context) { return } - blameReader, err := git.CreateBlameReader(ctx, repo_model.RepoPath(userName, repoName), commitID, fileName) + bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore")) + + result, err := performBlame(ctx, ctx.Repo.Repository.RepoPath(), ctx.Repo.Commit, fileName, bypassBlameIgnore) if err != nil { ctx.NotFound("CreateBlameReader", err) return } - defer blameReader.Close() - - blameParts := make([]git.BlamePart, 0) - for { - blamePart, err := blameReader.NextPart() - if err != nil { - ctx.NotFound("NextPart", err) - return - } - if blamePart == nil { - break - } - blameParts = append(blameParts, *blamePart) - } + ctx.Data["UsesIgnoreRevs"] = result.UsesIgnoreRevs + ctx.Data["FaultyIgnoreRevsFile"] = result.FaultyIgnoreRevsFile // Get Topics of this repo renderRepoTopics(ctx) @@ -128,22 +123,94 @@ func RefBlame(ctx *context.Context) { return } - commitNames, previousCommits := processBlameParts(ctx, blameParts) + commitNames := processBlameParts(ctx, result.Parts) if ctx.Written() { return } - renderBlame(ctx, blameParts, commitNames, previousCommits) + renderBlame(ctx, result.Parts, commitNames) ctx.HTML(http.StatusOK, tplRepoHome) } -func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[string]*user_model.UserCommit, map[string]string) { +type blameResult struct { + Parts []*git.BlamePart + UsesIgnoreRevs bool + FaultyIgnoreRevsFile bool +} + +func performBlame(ctx *context.Context, repoPath string, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { + objectFormat := ctx.Repo.GetObjectFormat() + + blameReader, err := git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, bypassBlameIgnore) + if err != nil { + return nil, err + } + + r := &blameResult{} + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + err = blameReader.Close() + if err != nil { + if len(r.Parts) == 0 && r.UsesIgnoreRevs { + // try again without ignored revs + + blameReader, err = git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, true) + if err != nil { + return nil, err + } + + r := &blameResult{ + FaultyIgnoreRevsFile: true, + } + if err := fillBlameResult(blameReader, r); err != nil { + _ = blameReader.Close() + return nil, err + } + + return r, blameReader.Close() + } + return nil, err + } + return r, nil +} + +func fillBlameResult(br *git.BlameReader, r *blameResult) error { + r.UsesIgnoreRevs = br.UsesIgnoreRevs() + + previousHelper := make(map[string]*git.BlamePart) + + r.Parts = make([]*git.BlamePart, 0, 5) + for { + blamePart, err := br.NextPart() + if err != nil { + return fmt.Errorf("BlameReader.NextPart failed: %w", err) + } + if blamePart == nil { + break + } + + if prev, ok := previousHelper[blamePart.Sha]; ok { + if blamePart.PreviousSha == "" { + blamePart.PreviousSha = prev.PreviousSha + blamePart.PreviousPath = prev.PreviousPath + } + } else { + previousHelper[blamePart.Sha] = blamePart + } + + r.Parts = append(r.Parts, blamePart) + } + + return nil +} + +func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[string]*user_model.UserCommit { // store commit data by SHA to look up avatar info etc commitNames := make(map[string]*user_model.UserCommit) - // previousCommits contains links from SHA to parent SHA, - // if parent also contains the current TreePath. - previousCommits := make(map[string]string) // and as blameParts can reference the same commits multiple // times, we cache the lookup work locally commits := make([]*git.Commit, 0, len(blameParts)) @@ -167,29 +234,11 @@ func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[st } else { ctx.ServerError("Repo.GitRepo.GetCommit", err) } - return nil, nil + return nil } commitCache[sha] = commit } - // find parent commit - if commit.ParentCount() > 0 { - psha := commit.Parents[0] - previousCommit, ok := commitCache[psha.String()] - if !ok { - previousCommit, _ = commit.Parent(0) - if previousCommit != nil { - commitCache[psha.String()] = previousCommit - } - } - // only store parent commit ONCE, if it has the file - if previousCommit != nil { - if haz1, _ := previousCommit.HasFile(ctx.Repo.TreePath); haz1 { - previousCommits[commit.ID.String()] = previousCommit.ID.String() - } - } - } - commits = append(commits, commit) } @@ -198,37 +247,17 @@ func processBlameParts(ctx *context.Context, blameParts []git.BlamePart) (map[st commitNames[c.ID.String()] = c } - return commitNames, previousCommits + return commitNames } -func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames map[string]*user_model.UserCommit, previousCommits map[string]string) { +func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { repoLink := ctx.Repo.RepoLink - language := "" - - indexFilename, worktree, deleteTemporaryFile, err := ctx.Repo.GitRepo.ReadTreeToTemporaryIndex(ctx.Repo.CommitID) - if err == nil { - defer deleteTemporaryFile() - - filename2attribute2info, err := ctx.Repo.GitRepo.CheckAttribute(git.CheckAttributeOpts{ - CachedOnly: true, - Attributes: []string{"linguist-language", "gitlab-language"}, - Filenames: []string{ctx.Repo.TreePath}, - IndexFile: indexFilename, - WorkTree: worktree, - }) - if err != nil { - log.Error("Unable to load attributes for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) - } - - language = filename2attribute2info[ctx.Repo.TreePath]["linguist-language"] - if language == "" || language == "unspecified" { - language = filename2attribute2info[ctx.Repo.TreePath]["gitlab-language"] - } - if language == "unspecified" { - language = "" - } + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) } + lines := make([]string, 0) rows := make([]*blameRow, 0) escapeStatus := &charset.EscapeStatus{} @@ -248,7 +277,6 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m } commit := commitNames[part.Sha] - previousSha := previousCommits[part.Sha] if index == 0 { // Count commit number commitCnt++ @@ -258,16 +286,16 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m var avatar string if commit.User != nil { - avatar = string(avatarUtils.Avatar(commit.User, 18, "gt-mr-3")) + avatar = string(avatarUtils.Avatar(commit.User, 18)) } else { - avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "gt-mr-3")) + avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "tw-mr-2")) } br.Avatar = gotemplate.HTML(avatar) br.RepoLink = repoLink br.PartSha = part.Sha - br.PreviousSha = previousSha - br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(previousSha), util.PathEscapeSegments(ctx.Repo.TreePath)) + br.PreviousSha = part.PreviousSha + br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) br.CommitMessage = commit.CommitMessage br.CommitSince = commitSince @@ -285,8 +313,7 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m lexerName = lexerNameForLine } - br.EscapeStatus, line = charset.EscapeControlHTML(line, ctx.Locale) - br.Code = gotemplate.HTML(line) + br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale) rows = append(rows, br) escapeStatus = escapeStatus.Or(br.EscapeStatus) } diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index f5831df28f6..f879a987866 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -16,14 +16,15 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" release_service "code.gitea.io/gitea/services/release" repo_service "code.gitea.io/gitea/services/repository" @@ -37,7 +38,7 @@ const ( func Branches(ctx *context.Context) { ctx.Data["Title"] = "Branches" ctx.Data["IsRepoToolbarBranches"] = true - ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls() + ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx) ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode) ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) || @@ -51,7 +52,9 @@ func Branches(ctx *context.Context) { } pageSize := setting.Git.BranchesRangeSize - defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, util.OptionalBoolNone, page, pageSize) + kw := ctx.FormString("q") + + defaultBranch, branches, branchesCount, err := repo_service.LoadBranches(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, optional.None[bool](), kw, page, pageSize) if err != nil { ctx.ServerError("LoadBranches", err) return @@ -73,6 +76,7 @@ func Branches(ctx *context.Context) { commitStatus[commitID] = git_model.CalcCommitStatus(cs) } + ctx.Data["Keyword"] = kw ctx.Data["Branches"] = branches ctx.Data["CommitStatus"] = commitStatus ctx.Data["CommitStatuses"] = commitStatuses @@ -144,11 +148,13 @@ func RestoreBranchPost(ctx *context.Context) { return } + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) + // Don't return error below this if err := repo_service.PushUpdate( &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromBranch(deletedBranch.Name), - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: deletedBranch.CommitID, PusherID: ctx.Doer.ID, PusherName: ctx.Doer.Name, @@ -188,9 +194,9 @@ func CreateBranch(ctx *context.Context) { } err = release_service.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, target, form.NewBranchName, "") } else if ctx.Repo.IsViewBranch { - err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.BranchName, form.NewBranchName) + err = repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.BranchName, form.NewBranchName) } else { - err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.CommitID, form.NewBranchName) + err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.CommitID, form.NewBranchName) } if err != nil { if models.IsErrProtectedTagName(err) { @@ -221,7 +227,7 @@ func CreateBranch(ctx *context.Context) { if len(e.Message) == 0 { ctx.Flash.Error(ctx.Tr("repo.editor.push_rejected_no_message")) } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.push_rejected"), "Summary": ctx.Tr("repo.editor.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(e.Message), diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go index 5017d022522..088f8d889d4 100644 --- a/routers/web/repo/cherry_pick.go +++ b/routers/web/repo/cherry_pick.go @@ -12,11 +12,11 @@ import ( git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/repository/files" ) @@ -104,9 +104,9 @@ func CherryPickPost(ctx *context.Context) { message := strings.TrimSpace(form.CommitSummary) if message == "" { if form.Revert { - message = ctx.Tr("repo.commit.revert-header", sha) + message = ctx.Locale.TrString("repo.commit.revert-header", sha) } else { - message = ctx.Tr("repo.commit.cherry-pick-header", sha) + message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha) } } @@ -171,10 +171,9 @@ func CherryPickPost(ctx *context.Context) { } else if models.IsErrCommitIDDoesNotMatch(err) { ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) return - } else { - ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) - return } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return } } diff --git a/routers/web/repo/code_frequency.go b/routers/web/repo/code_frequency.go new file mode 100644 index 00000000000..c76f492da07 --- /dev/null +++ b/routers/web/repo/code_frequency.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplCodeFrequency base.TplName = "repo/activity" +) + +// CodeFrequency renders the page to show repository code frequency +func CodeFrequency(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency") + + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsCodeFrequency"] = true + ctx.PageData["repoLink"] = ctx.Repo.RepoLink + + ctx.HTML(http.StatusOK, tplCodeFrequency) +} + +// CodeFrequencyData returns JSON of code frequency data +func CodeFrequencyData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetCodeFrequencyData", err) + } else { + ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) + } +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 9a620f6d372..8543fa44cc7 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -7,7 +7,9 @@ package repo import ( "errors" "fmt" + "html/template" "net/http" + "path" "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -17,11 +19,14 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitgraph" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/gitdiff" git_service "code.gitea.io/gitea/services/repository" ) @@ -158,8 +163,8 @@ func Graph(ctx *context.Context) { ctx.Data["CommitCount"] = commitsCount paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) - paginator.AddParam(ctx, "mode", "Mode") - paginator.AddParam(ctx, "hide-pr-refs", "HidePRRefs") + paginator.AddParamString("mode", mode) + paginator.AddParamString("hide-pr-refs", fmt.Sprint(hidePRRefs)) for _, branch := range branches { paginator.AddParamString("branch", branch) } @@ -198,7 +203,7 @@ func SearchCommits(ctx *context.Context) { ctx.Data["Keyword"] = query if all { - ctx.Data["All"] = "checked" + ctx.Data["All"] = true } ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name @@ -275,7 +280,7 @@ func Diff(ctx *context.Context) { ) if ctx.Data["PageIsWiki"] != nil { - gitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.WikiPath()) + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError("Repo.GitRepo.GetCommit", err) return @@ -294,7 +299,7 @@ func Diff(ctx *context.Context) { } return } - if len(commitID) != git.SHAFullLength { + if len(commitID) != commit.ID.Type().FullLength() { commitID = commit.ID.String() } @@ -305,7 +310,7 @@ func Diff(ctx *context.Context) { maxLines, maxFiles = -1, -1 } - diff, err := gitdiff.GetDiff(gitRepo, &gitdiff.DiffOptions{ + diff, err := gitdiff.GetDiff(ctx, gitRepo, &gitdiff.DiffOptions{ AfterCommitID: commitID, SkipTo: ctx.FormString("skip-to"), MaxLines: maxLines, @@ -346,7 +351,7 @@ func Diff(ctx *context.Context) { ctx.Data["Commit"] = commit ctx.Data["Diff"] = diff - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptions{ListAll: true}) + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } @@ -361,7 +366,7 @@ func Diff(ctx *context.Context) { ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { - return repo_model.IsOwnerMemberCollaborator(ctx.Repo.Repository, user.ID) + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) }, nil); err != nil { ctx.ServerError("CalculateTrustStatus", err) return @@ -370,9 +375,21 @@ func Diff(ctx *context.Context) { note := &git.Note{} err = git.GetNote(ctx, ctx.Repo.GitRepo, commitID, note) if err == nil { - ctx.Data["Note"] = string(charset.ToUTF8WithFallback(note.Message)) ctx.Data["NoteCommit"] = note.Commit ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) + ctx.Data["NoteRendered"], err = markup.RenderCommitMessage(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: path.Join("commit", util.PathEscapeSegments(commitID)), + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, + }, template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) + if err != nil { + ctx.ServerError("RenderCommitMessage", err) + return + } } ctx.Data["BranchName"], err = commit.GetBranchName() @@ -388,7 +405,7 @@ func Diff(ctx *context.Context) { func RawDiff(ctx *context.Context) { var gitRepo *git.Repository if ctx.Data["PageIsWiki"] != nil { - wikiRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.WikiPath()) + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError("OpenRepository", err) return diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index ecc8e66702b..cfb0e859bd7 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -25,15 +25,18 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" csv_module "code.gitea.io/gitea/modules/csv" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/upload" + "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/gitdiff" ) @@ -60,6 +63,21 @@ func setCompareContext(ctx *context.Context, before, head *git.Commit, headOwner return blob } + ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType { + st := typesniffer.SniffedType{} + + if blob == nil { + return st + } + + st, err := blob.GuessContentType() + if err != nil { + log.Error("GuessContentType failed: %v", err) + return st + } + return st + } + setPathsCompareContext(ctx, before, head, headOwner, headName) setImageCompareContext(ctx) setCsvCompareContext(ctx) @@ -87,16 +105,7 @@ func setPathsCompareContext(ctx *context.Context, base, head *git.Commit, headOw // setImageCompareContext sets context data that is required by image compare template func setImageCompareContext(ctx *context.Context) { - ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { - if blob == nil { - return false - } - - st, err := blob.GuessContentType() - if err != nil { - log.Error("GuessContentType failed: %v", err) - return false - } + ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool { return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) } } @@ -118,7 +127,7 @@ func setCsvCompareContext(ctx *context.Context) { return CsvDiffResult{nil, ""} } - errTooLarge := errors.New(ctx.Locale.Tr("repo.error.csv.too_large")) + errTooLarge := errors.New(ctx.Locale.TrString("repo.error.csv.too_large")) csvReaderFromCommit := func(ctx *markup.RenderContext, blob *git.Blob) (*csv.Reader, io.Closer, error) { if blob == nil { @@ -135,7 +144,7 @@ func setCsvCompareContext(ctx *context.Context) { return nil, nil, err } - csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader)) + csvReader, err := csv_module.CreateReaderAndDetermineDelimiter(ctx, charset.ToUTF8WithFallbackReader(reader, charset.ConvertOpts{})) return csvReader, reader, err } @@ -252,7 +261,6 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo { isSameRepo = true ci.HeadUser = ctx.Repo.Owner ci.HeadBranch = headInfos[0] - } else if len(headInfos) == 2 { headInfosSplit := strings.Split(headInfos[0], "/") if len(headInfosSplit) == 1 { @@ -304,13 +312,14 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo { baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(ci.BaseBranch) baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(ci.BaseBranch) baseIsTag := ctx.Repo.GitRepo.IsTagExist(ci.BaseBranch) + if !baseIsCommit && !baseIsBranch && !baseIsTag { // Check if baseBranch is short sha commit hash if baseCommit, _ := ctx.Repo.GitRepo.GetCommit(ci.BaseBranch); baseCommit != nil { ci.BaseBranch = baseCommit.ID.String() ctx.Data["BaseBranch"] = ci.BaseBranch baseIsCommit = true - } else if ci.BaseBranch == git.EmptySHA { + } else if ci.BaseBranch == ctx.Repo.GetObjectFormat().EmptyObjectID().String() { if isSameRepo { ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ci.HeadBranch)) } else { @@ -401,12 +410,15 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo { ci.HeadRepo = ctx.Repo.Repository ci.HeadGitRepo = ctx.Repo.GitRepo } else if has { - ci.HeadGitRepo, err = git.OpenRepository(ctx, ci.HeadRepo.RepoPath()) + ci.HeadGitRepo, err = gitrepo.OpenRepository(ctx, ci.HeadRepo) if err != nil { ctx.ServerError("OpenRepository", err) return nil } defer ci.HeadGitRepo.Close() + } else { + ctx.NotFound("ParseCompareInfo", nil) + return nil } ctx.Data["HeadRepo"] = ci.HeadRepo @@ -609,7 +621,7 @@ func PrepareCompareDiff( maxLines, maxFiles = -1, -1 } - diff, err := gitdiff.GetDiff(ci.HeadGitRepo, + diff, err := gitdiff.GetDiff(ctx, ci.HeadGitRepo, &gitdiff.DiffOptions{ BeforeCommitID: beforeCommitID, AfterCommitID: headCommitID, @@ -678,18 +690,16 @@ func PrepareCompareDiff( } func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repository) (branches, tags []string, err error) { - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { return nil, nil, err } defer gitRepo.Close() branches, err = git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ - RepoID: repo.ID, - ListOptions: db.ListOptions{ - ListAll: true, - }, - IsDeletedBranch: util.OptionalBoolFalse, + RepoID: repo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), }) if err != nil { return nil, nil, err @@ -742,11 +752,9 @@ func CompareDiff(ctx *context.Context) { } headBranches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ - RepoID: ci.HeadRepo.ID, - ListOptions: db.ListOptions{ - ListAll: true, - }, - IsDeletedBranch: util.OptionalBoolFalse, + RepoID: ci.HeadRepo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), }) if err != nil { ctx.ServerError("GetBranches", err) @@ -835,6 +843,7 @@ func CompareDiff(ctx *context.Context) { } } + ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanWrite(unit.TypeProjects) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -865,7 +874,7 @@ func ExcerptBlob(ctx *context.Context) { gitRepo := ctx.Repo.GitRepo if ctx.FormBool("wiki") { var err error - gitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.WikiPath()) + gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError("OpenRepository", err) return @@ -967,5 +976,8 @@ func getExcerptLines(commit *git.Commit, filePath string, idxLeft, idxRight, chu } diffLines = append(diffLines, diffLine) } + if err = scanner.Err(); err != nil { + return nil, fmt.Errorf("getExcerptLines scan: %w", err) + } return diffLines, nil } diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go new file mode 100644 index 00000000000..5fda17469e9 --- /dev/null +++ b/routers/web/repo/contributors.go @@ -0,0 +1,44 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/services/context" + contributors_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplContributors base.TplName = "repo/activity" +) + +// Contributors render the page to show repository contributors graph +func Contributors(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.contributors") + + ctx.Data["PageIsActivity"] = true + ctx.Data["PageIsContributors"] = true + + ctx.PageData["contributionType"] = "commits" + + ctx.PageData["repoLink"] = ctx.Repo.RepoLink + + ctx.HTML(http.StatusOK, tplContributors) +} + +// ContributorsData renders JSON of contributors along with their weekly commit statistics +func ContributorsData(ctx *context.Context) { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if errors.Is(err, contributors_service.ErrAwaitGeneration) { + ctx.Status(http.StatusAccepted) + return + } + ctx.ServerError("GetContributorStats", err) + } else { + ctx.JSON(http.StatusOK, contributorStats) + } +} diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index a9e2e2b2fad..c4a8baecca3 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -9,7 +9,6 @@ import ( "time" git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/lfs" @@ -17,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" ) // ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 0a606582e58..474f7ff1da4 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -16,17 +16,17 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" files_service "code.gitea.io/gitea/services/repository/files" ) @@ -80,8 +80,12 @@ func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, } } - // Redirect to viewing file or folder - ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(newBranchName) + "/" + util.PathEscapeSegments(treePath)) + returnURI := ctx.FormString("return_uri") + + ctx.RedirectToCurrentSite( + returnURI, + ctx.Repo.RepoLink+"/src/branch/"+util.PathEscapeSegments(newBranchName)+"/"+util.PathEscapeSegments(treePath), + ) } // getParentTreeFields returns list of parent tree names and corresponding tree paths @@ -100,6 +104,7 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) { } func editFile(ctx *context.Context, isNewFile bool) { + ctx.Data["PageIsViewCode"] = true ctx.Data["PageIsEdit"] = true ctx.Data["IsNewFile"] = isNewFile canCommit := renderCommitRights(ctx) @@ -123,7 +128,7 @@ func editFile(ctx *context.Context, isNewFile bool) { if !isNewFile { entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return } @@ -161,13 +166,10 @@ func editFile(ctx *context.Context, isNewFile bool) { } d, _ := io.ReadAll(dataRc) - if err := dataRc.Close(); err != nil { - log.Error("Error whilst closing blob data: %v", err) - } buf = append(buf, d...) - if content, err := charset.ToUTF8WithErr(buf); err != nil { - log.Error("ToUTF8WithErr: %v", err) + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + log.Error("ToUTF8: %v", err) ctx.Data["FileContent"] = string(buf) } else { ctx.Data["FileContent"] = content @@ -193,6 +195,9 @@ func editFile(ctx *context.Context, isNewFile bool) { ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, treePath) + ctx.Data["IsEditingFileOnly"] = ctx.FormString("return_uri") != "" + ctx.Data["ReturnURI"] = ctx.FormString("return_uri") + ctx.HTML(http.StatusOK, tplEditFile) } @@ -262,9 +267,9 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b message := strings.TrimSpace(form.CommitSummary) if len(message) == 0 { if isNewFile { - message = ctx.Tr("repo.editor.add", form.TreePath) + message = ctx.Locale.TrString("repo.editor.add", form.TreePath) } else { - message = ctx.Tr("repo.editor.update", form.TreePath) + message = ctx.Locale.TrString("repo.editor.update", form.TreePath) } } form.CommitMessage = strings.TrimSpace(form.CommitMessage) @@ -336,15 +341,15 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b ctx.Error(http.StatusInternalServerError, err.Error()) } } else if models.IsErrCommitIDDoesNotMatch(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(form.LastCommit)+"..."+util.PathEscapeSegments(ctx.Repo.CommitID)), tplEditFile, &form) + ctx.RenderWithErr(ctx.Tr("repo.editor.commit_id_not_matching"), tplEditFile, &form) } else if git.IsErrPushOutOfDate(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(form.LastCommit)+"..."+util.PathEscapeSegments(form.NewBranchName)), tplEditFile, &form) + ctx.RenderWithErr(ctx.Tr("repo.editor.push_out_of_date"), tplEditFile, &form) } else if git.IsErrPushRejected(err) { errPushRej := err.(*git.ErrPushRejected) if len(errPushRej.Message) == 0 { ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplEditFile, &form) } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.push_rejected"), "Summary": ctx.Tr("repo.editor.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(errPushRej.Message), @@ -356,7 +361,7 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b ctx.RenderWithErr(flashError, tplEditFile, &form) } } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.fail_to_update_file", form.TreePath), "Summary": ctx.Tr("repo.editor.fail_to_update_file_summary"), "Details": utils.SanitizeFlashErrorString(err.Error()), @@ -415,7 +420,7 @@ func DiffPreviewPost(ctx *context.Context) { } if diff.NumFiles == 0 { - ctx.PlainText(http.StatusOK, ctx.Tr("repo.editor.no_changes_to_show")) + ctx.PlainText(http.StatusOK, ctx.Locale.TrString("repo.editor.no_changes_to_show")) return } ctx.Data["File"] = diff.Files[0] @@ -482,7 +487,7 @@ func DeleteFilePost(ctx *context.Context) { message := strings.TrimSpace(form.CommitSummary) if len(message) == 0 { - message = ctx.Tr("repo.editor.delete", ctx.Repo.TreePath) + message = ctx.Locale.TrString("repo.editor.delete", ctx.Repo.TreePath) } form.CommitMessage = strings.TrimSpace(form.CommitMessage) if len(form.CommitMessage) > 0 { @@ -545,7 +550,7 @@ func DeleteFilePost(ctx *context.Context) { if len(errPushRej.Message) == 0 { ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplDeleteFile, &form) } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.push_rejected"), "Summary": ctx.Tr("repo.editor.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(errPushRej.Message), @@ -691,7 +696,7 @@ func UploadFilePost(ctx *context.Context) { if dir == "" { dir = "/" } - message = ctx.Tr("repo.editor.upload_files_to_dir", dir) + message = ctx.Locale.TrString("repo.editor.upload_files_to_dir", dir) } form.CommitMessage = strings.TrimSpace(form.CommitMessage) @@ -745,7 +750,7 @@ func UploadFilePost(ctx *context.Context) { if len(errPushRej.Message) == 0 { ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplUploadFile, &form) } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.push_rejected"), "Summary": ctx.Tr("repo.editor.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(errPushRej.Message), diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go index 67fb277d5c2..313fcfe33a2 100644 --- a/routers/web/repo/editor_test.go +++ b/routers/web/repo/editor_test.go @@ -7,8 +7,9 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -66,7 +67,7 @@ func TestGetClosestParentWithFiles(t *testing.T) { repo := ctx.Repo.Repository branch := repo.DefaultBranch - gitRepo, _ := git.OpenRepository(git.DefaultContext, repo.RepoPath()) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) defer gitRepo.Close() commit, _ := gitRepo.GetBranchCommit(branch) var expectedTreePath string // Should return the root dir, empty string, since there are no subdirs in this repo diff --git a/routers/web/repo/find.go b/routers/web/repo/find.go index daefe59c8f2..9da4237c1ed 100644 --- a/routers/web/repo/find.go +++ b/routers/web/repo/find.go @@ -7,7 +7,8 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -17,7 +18,7 @@ const ( // FindFiles render the page to find repository files func FindFiles(ctx *context.Context) { path := ctx.Params("*") - ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + path - ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + path + ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path) + ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path) ctx.HTML(http.StatusOK, tplFindFiles) } diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go new file mode 100644 index 00000000000..27e42a8f98e --- /dev/null +++ b/routers/web/repo/fork.go @@ -0,0 +1,236 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + "net/url" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplFork base.TplName = "repo/pulls/fork" +) + +func getForkRepository(ctx *context.Context) *repo_model.Repository { + forkRepo := ctx.Repo.Repository + if ctx.Written() { + return nil + } + + if forkRepo.IsEmpty { + log.Trace("Empty repository %-v", forkRepo) + ctx.NotFound("getForkRepository", nil) + return nil + } + + if err := forkRepo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return nil + } + + ctx.Data["repo_name"] = forkRepo.Name + ctx.Data["description"] = forkRepo.Description + ctx.Data["IsPrivate"] = forkRepo.IsPrivate || forkRepo.Owner.Visibility == structs.VisibleTypePrivate + canForkToUser := forkRepo.OwnerID != ctx.Doer.ID && !repo_model.HasForkedRepo(ctx, ctx.Doer.ID, forkRepo.ID) + + ctx.Data["ForkRepo"] = forkRepo + + ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) + return nil + } + var orgs []*organization.Organization + for _, org := range ownedOrgs { + if forkRepo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, forkRepo.ID) { + orgs = append(orgs, org) + } + } + + traverseParentRepo := forkRepo + for { + if ctx.Doer.ID == traverseParentRepo.OwnerID { + canForkToUser = false + } else { + for i, org := range orgs { + if org.ID == traverseParentRepo.OwnerID { + orgs = append(orgs[:i], orgs[i+1:]...) + break + } + } + } + + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return nil + } + } + + ctx.Data["CanForkToUser"] = canForkToUser + ctx.Data["Orgs"] = orgs + + if canForkToUser { + ctx.Data["ContextUser"] = ctx.Doer + } else if len(orgs) > 0 { + ctx.Data["ContextUser"] = orgs[0] + } else { + ctx.Data["CanForkRepo"] = false + ctx.Flash.Error(ctx.Tr("repo.fork_no_valid_owners"), true) + return nil + } + + branches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), + // Add it as the first option + ExcludeBranchNames: []string{ctx.Repo.Repository.DefaultBranch}, + }) + if err != nil { + ctx.ServerError("FindBranchNames", err) + return nil + } + ctx.Data["Branches"] = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + + return forkRepo +} + +// Fork render repository fork page +func Fork(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("new_fork") + + if ctx.Doer.CanForkRepo() { + ctx.Data["CanForkRepo"] = true + } else { + maxCreationLimit := ctx.Doer.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg, true) + } + + getForkRepository(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplFork) +} + +// ForkPost response for forking a repository +func ForkPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateRepoForm) + ctx.Data["Title"] = ctx.Tr("new_fork") + ctx.Data["CanForkRepo"] = true + + ctxUser := checkContextUser(ctx, form.UID) + if ctx.Written() { + return + } + + forkRepo := getForkRepository(ctx) + if ctx.Written() { + return + } + + ctx.Data["ContextUser"] = ctxUser + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplFork) + return + } + + var err error + traverseParentRepo := forkRepo + for { + if ctxUser.ID == traverseParentRepo.OwnerID { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + return + } + repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) + if repo != nil { + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + return + } + if !traverseParentRepo.IsFork { + break + } + traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + } + + // Check if user is allowed to create repo's on the organization. + if ctxUser.IsOrganization() { + isAllowedToFork, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return + } else if !isAllowedToFork { + ctx.Error(http.StatusForbidden) + return + } + } + + repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ + BaseRepo: forkRepo, + Name: form.RepoName, + Description: form.Description, + SingleBranch: form.ForkSingleBranch, + }) + if err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrReachLimitOfRepo(err): + maxCreationLimit := ctxUser.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.RenderWithErr(msg, tplFork, &form) + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + case repo_model.IsErrRepoFilesAlreadyExist(err): + switch { + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) + default: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) + } + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + case errors.Is(err, user_model.ErrBlockedUser): + ctx.RenderWithErr(ctx.Tr("repo.fork.blocked_user"), tplFork, form) + default: + ctx.ServerError("ForkPost", err) + } + return + } + + log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) +} diff --git a/routers/web/repo/http.go b/routers/web/repo/githttp.go similarity index 74% rename from routers/web/repo/http.go rename to routers/web/repo/githttp.go index 1fd784a40a0..8fb6d930688 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/githttp.go @@ -11,7 +11,7 @@ import ( "fmt" "net/http" "os" - "path" + "path/filepath" "regexp" "strconv" "strings" @@ -24,13 +24,13 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" "github.com/go-chi/cors" @@ -90,11 +90,10 @@ func httpBase(ctx *context.Context) *serviceHandler { isWiki := false unitType := unit.TypeCode - var wikiRepoName string + if strings.HasSuffix(reponame, ".wiki") { isWiki = true unitType = unit.TypeWiki - wikiRepoName = reponame reponame = reponame[:len(reponame)-5] } @@ -105,18 +104,18 @@ func httpBase(ctx *context.Context) *serviceHandler { } repoExist := true - repo, err := repo_model.GetRepositoryByName(owner.ID, reponame) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) if err != nil { - if repo_model.IsErrRepoNotExist(err) { - if redirectRepoID, err := repo_model.LookupRedirect(owner.ID, reponame); err == nil { - context.RedirectToRepo(ctx.Base, redirectRepoID) - return nil - } - repoExist = false - } else { + if !repo_model.IsErrRepoNotExist(err) { ctx.ServerError("GetRepositoryByName", err) return nil } + + if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { + context.RedirectToRepo(ctx.Base, redirectRepoID) + return nil + } + repoExist = false } // Don't allow pushing if the repo is archived @@ -184,7 +183,7 @@ func httpBase(ctx *context.Context) *serviceHandler { if repoExist { // Because of special ref "refs/for" .. , need delay write permission check - if git.SupportProcReceive { + if git.DefaultFeatures.SupportProcReceive { accessMode = perm.AccessModeRead } @@ -292,22 +291,9 @@ func httpBase(ctx *context.Context) *serviceHandler { environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) - w := ctx.Resp - r := ctx.Req - cfg := &serviceConfig{ - UploadPack: true, - ReceivePack: true, - Env: environ, - } - - r.URL.Path = strings.ToLower(r.URL.Path) // blue: In case some repo name has upper case name + ctx.Req.URL.Path = strings.ToLower(ctx.Req.URL.Path) // blue: In case some repo name has upper case name - dir := repo_model.RepoPath(username, reponame) - if isWiki { - dir = repo_model.RepoPath(username, wikiRepoName) - } - - return &serviceHandler{cfg, w, r, dir, cfg.Env} + return &serviceHandler{repo, isWiki, environ} } var ( @@ -329,7 +315,7 @@ func dummyInfoRefs(ctx *context.Context) { } }() - if err := git.InitRepository(ctx, tmpDir, true); err != nil { + if err := git.InitRepository(ctx, tmpDir, true, git.Sha1ObjectFormat.Name()); err != nil { log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) return } @@ -352,32 +338,31 @@ func dummyInfoRefs(ctx *context.Context) { _, _ = ctx.Write(infoRefsCache) } -type serviceConfig struct { - UploadPack bool - ReceivePack bool - Env []string -} - type serviceHandler struct { - cfg *serviceConfig - w http.ResponseWriter - r *http.Request - dir string + repo *repo_model.Repository + isWiki bool environ []string } -func (h *serviceHandler) setHeaderNoCache() { - h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") - h.w.Header().Set("Pragma", "no-cache") - h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") +func (h *serviceHandler) getRepoDir() string { + if h.isWiki { + return h.repo.WikiPath() + } + return h.repo.RepoPath() +} + +func setHeaderNoCache(ctx *context.Context) { + ctx.Resp.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.Resp.Header().Set("Pragma", "no-cache") + ctx.Resp.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") } -func (h *serviceHandler) setHeaderCacheForever() { +func setHeaderCacheForever(ctx *context.Context) { now := time.Now().Unix() expires := now + 31536000 - h.w.Header().Set("Date", fmt.Sprintf("%d", now)) - h.w.Header().Set("Expires", fmt.Sprintf("%d", expires)) - h.w.Header().Set("Cache-Control", "public, max-age=31536000") + ctx.Resp.Header().Set("Date", fmt.Sprintf("%d", now)) + ctx.Resp.Header().Set("Expires", fmt.Sprintf("%d", expires)) + ctx.Resp.Header().Set("Cache-Control", "public, max-age=31536000") } func containsParentDirectorySeparator(v string) bool { @@ -394,71 +379,71 @@ func containsParentDirectorySeparator(v string) bool { func isSlashRune(r rune) bool { return r == '/' || r == '\\' } -func (h *serviceHandler) sendFile(contentType, file string) { +func (h *serviceHandler) sendFile(ctx *context.Context, contentType, file string) { if containsParentDirectorySeparator(file) { log.Error("request file path contains invalid path: %v", file) - h.w.WriteHeader(http.StatusBadRequest) + ctx.Resp.WriteHeader(http.StatusBadRequest) return } - reqFile := path.Join(h.dir, file) + reqFile := filepath.Join(h.getRepoDir(), file) fi, err := os.Stat(reqFile) if os.IsNotExist(err) { - h.w.WriteHeader(http.StatusNotFound) + ctx.Resp.WriteHeader(http.StatusNotFound) return } - h.w.Header().Set("Content-Type", contentType) - h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) - h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) - http.ServeFile(h.w, h.r, reqFile) + ctx.Resp.Header().Set("Content-Type", contentType) + ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + ctx.Resp.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) + http.ServeFile(ctx.Resp, ctx.Req, reqFile) } // one or more key=value pairs separated by colons var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) -func prepareGitCmdWithAllowedService(service string, h *serviceHandler) (*git.Command, error) { - if service == "receive-pack" && h.cfg.ReceivePack { - return git.NewCommand(h.r.Context(), "receive-pack"), nil +func prepareGitCmdWithAllowedService(ctx *context.Context, service string) (*git.Command, error) { + if service == "receive-pack" { + return git.NewCommand(ctx, "receive-pack"), nil } - if service == "upload-pack" && h.cfg.UploadPack { - return git.NewCommand(h.r.Context(), "upload-pack"), nil + if service == "upload-pack" { + return git.NewCommand(ctx, "upload-pack"), nil } return nil, fmt.Errorf("service %q is not allowed", service) } -func serviceRPC(h *serviceHandler, service string) { +func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { defer func() { - if err := h.r.Body.Close(); err != nil { + if err := ctx.Req.Body.Close(); err != nil { log.Error("serviceRPC: Close: %v", err) } }() expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) - if h.r.Header.Get("Content-Type") != expectedContentType { - log.Error("Content-Type (%q) doesn't match expected: %q", h.r.Header.Get("Content-Type"), expectedContentType) - h.w.WriteHeader(http.StatusUnauthorized) + if ctx.Req.Header.Get("Content-Type") != expectedContentType { + log.Error("Content-Type (%q) doesn't match expected: %q", ctx.Req.Header.Get("Content-Type"), expectedContentType) + ctx.Resp.WriteHeader(http.StatusUnauthorized) return } - cmd, err := prepareGitCmdWithAllowedService(service, h) + cmd, err := prepareGitCmdWithAllowedService(ctx, service) if err != nil { log.Error("Failed to prepareGitCmdWithService: %v", err) - h.w.WriteHeader(http.StatusUnauthorized) + ctx.Resp.WriteHeader(http.StatusUnauthorized) return } - h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) - reqBody := h.r.Body + reqBody := ctx.Req.Body // Handle GZIP. - if h.r.Header.Get("Content-Encoding") == "gzip" { + if ctx.Req.Header.Get("Content-Encoding") == "gzip" { reqBody, err = gzip.NewReader(reqBody) if err != nil { log.Error("Fail to create gzip reader: %v", err) - h.w.WriteHeader(http.StatusInternalServerError) + ctx.Resp.WriteHeader(http.StatusInternalServerError) return } } @@ -466,23 +451,23 @@ func serviceRPC(h *serviceHandler, service string) { // set this for allow pre-receive and post-receive execute h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service) - if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) } var stderr bytes.Buffer - cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.dir) - cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.dir)) + cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.getRepoDir()) + cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.getRepoDir())) if err := cmd.Run(&git.RunOpts{ - Dir: h.dir, + Dir: h.getRepoDir(), Env: append(os.Environ(), h.environ...), - Stdout: h.w, + Stdout: ctx.Resp, Stdin: reqBody, Stderr: &stderr, UseContextTimeout: true, }); err != nil { if err.Error() != "signal: killed" { - log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.dir, err, stderr.String()) + log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.getRepoDir(), err, stderr.String()) } return } @@ -492,7 +477,7 @@ func serviceRPC(h *serviceHandler, service string) { func ServiceUploadPack(ctx *context.Context) { h := httpBase(ctx) if h != nil { - serviceRPC(h, "upload-pack") + serviceRPC(ctx, h, "upload-pack") } } @@ -500,12 +485,12 @@ func ServiceUploadPack(ctx *context.Context) { func ServiceReceivePack(ctx *context.Context) { h := httpBase(ctx) if h != nil { - serviceRPC(h, "receive-pack") + serviceRPC(ctx, h, "receive-pack") } } -func getServiceType(r *http.Request) string { - serviceType := r.FormValue("service") +func getServiceType(ctx *context.Context) string { + serviceType := ctx.Req.FormValue("service") if !strings.HasPrefix(serviceType, "git-") { return "" } @@ -534,28 +519,28 @@ func GetInfoRefs(ctx *context.Context) { if h == nil { return } - h.setHeaderNoCache() - service := getServiceType(h.r) - cmd, err := prepareGitCmdWithAllowedService(service, h) + setHeaderNoCache(ctx) + service := getServiceType(ctx) + cmd, err := prepareGitCmdWithAllowedService(ctx, service) if err == nil { - if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) } h.environ = append(os.Environ(), h.environ...) - refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.dir}) + refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.getRepoDir()}) if err != nil { log.Error(fmt.Sprintf("%v - %s", err, string(refs))) } - h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) - h.w.WriteHeader(http.StatusOK) - _, _ = h.w.Write(packetWrite("# service=git-" + service + "\n")) - _, _ = h.w.Write([]byte("0000")) - _, _ = h.w.Write(refs) + ctx.Resp.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(packetWrite("# service=git-" + service + "\n")) + _, _ = ctx.Resp.Write([]byte("0000")) + _, _ = ctx.Resp.Write(refs) } else { - updateServerInfo(ctx, h.dir) - h.sendFile("text/plain; charset=utf-8", "info/refs") + updateServerInfo(ctx, h.getRepoDir()) + h.sendFile(ctx, "text/plain; charset=utf-8", "info/refs") } } @@ -564,12 +549,12 @@ func GetTextFile(p string) func(*context.Context) { return func(ctx *context.Context) { h := httpBase(ctx) if h != nil { - h.setHeaderNoCache() + setHeaderNoCache(ctx) file := ctx.Params("file") if file != "" { - h.sendFile("text/plain", "objects/info/"+file) + h.sendFile(ctx, "text/plain", "objects/info/"+file) } else { - h.sendFile("text/plain", p) + h.sendFile(ctx, "text/plain", p) } } } @@ -579,8 +564,8 @@ func GetTextFile(p string) func(*context.Context) { func GetInfoPacks(ctx *context.Context) { h := httpBase(ctx) if h != nil { - h.setHeaderCacheForever() - h.sendFile("text/plain; charset=utf-8", "objects/info/packs") + setHeaderCacheForever(ctx) + h.sendFile(ctx, "text/plain; charset=utf-8", "objects/info/packs") } } @@ -588,8 +573,8 @@ func GetInfoPacks(ctx *context.Context) { func GetLooseObject(ctx *context.Context) { h := httpBase(ctx) if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", ctx.Params("head"), ctx.Params("hash"))) } } @@ -598,8 +583,8 @@ func GetLooseObject(ctx *context.Context) { func GetPackFile(ctx *context.Context) { h := httpBase(ctx) if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") } } @@ -607,7 +592,7 @@ func GetPackFile(ctx *context.Context) { func GetIdxFile(ctx *context.Context) { h := httpBase(ctx) if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") + setHeaderCacheForever(ctx) + h.sendFile(ctx, "application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") } } diff --git a/routers/web/repo/http_test.go b/routers/web/repo/githttp_test.go similarity index 100% rename from routers/web/repo/http_test.go rename to routers/web/repo/githttp_test.go diff --git a/routers/web/repo/helper.go b/routers/web/repo/helper.go index f8cdefdc8ef..5e1e116018e 100644 --- a/routers/web/repo/helper.go +++ b/routers/web/repo/helper.go @@ -4,9 +4,12 @@ package repo import ( + "net/url" "sort" "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" ) func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { @@ -20,3 +23,22 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { } return users } + +func HandleGitError(ctx *context.Context, msg string, err error) { + if git.IsErrNotExist(err) { + refType := "" + switch { + case ctx.Repo.IsViewBranch: + refType = "branch" + case ctx.Repo.IsViewTag: + refType = "tag" + case ctx.Repo.IsViewCommit: + refType = "commit" + } + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found_"+refType, ctx.Repo.TreePath, url.PathEscape(ctx.Repo.RefName)) + ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + refType + "/" + url.PathEscape(ctx.Repo.RefName) + ctx.NotFound(msg, err) + } else { + ctx.ServerError(msg, err) + } +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 94c9382f23b..e4f2e9a2bc2 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -9,6 +9,7 @@ import ( stdCtx "context" "errors" "fmt" + "html/template" "math/big" "net/http" "net/url" @@ -31,28 +32,32 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/git" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates/vars" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -136,7 +141,7 @@ func MustAllowPulls(ctx *context.Context) { } } -func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption util.OptionalBool) { +func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { var err error viewType := ctx.FormString("type") sortType := ctx.FormString("sort") @@ -182,8 +187,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti if len(selectLabels) > 0 { labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) if err != nil { - ctx.ServerError("StringsToInt64s", err) - return + ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) } } @@ -198,64 +202,83 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } var issueStats *issues_model.IssueStats - { - statsOpts := &issues_model.IssuesOptions{ - RepoIDs: []int64{repo.ID}, - LabelIDs: labelIDs, - MilestoneIDs: mileIDs, - ProjectID: projectID, - AssigneeID: assigneeID, - MentionedID: mentionedID, - PosterID: posterID, - ReviewRequestedID: reviewRequestedID, - ReviewedID: reviewedID, - IsPull: isPullOption, - IssueIDs: nil, - } - if keyword != "" { - allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) - if err != nil { - if issue_indexer.IsAvailable(ctx) { - ctx.ServerError("issueIDsFromSearch", err) - return - } - ctx.Data["IssueIndexerUnavailable"] = true + statsOpts := &issues_model.IssuesOptions{ + RepoIDs: []int64{repo.ID}, + LabelIDs: labelIDs, + MilestoneIDs: mileIDs, + ProjectID: projectID, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsPull: isPullOption, + IssueIDs: nil, + } + if keyword != "" { + allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) return } - statsOpts.IssueIDs = allIssueIDs + ctx.Data["IssueIndexerUnavailable"] = true + return } - if keyword != "" && len(statsOpts.IssueIDs) == 0 { - // So it did search with the keyword, but no issue found. - // Just set issueStats to empty. - issueStats = &issues_model.IssueStats{} - } else { - // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. - // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. - issueStats, err = issues_model.GetIssueStats(statsOpts) - if err != nil { - ctx.ServerError("GetIssueStats", err) - return - } + statsOpts.IssueIDs = allIssueIDs + } + if keyword != "" && len(statsOpts.IssueIDs) == 0 { + // So it did search with the keyword, but no issue found. + // Just set issueStats to empty. + issueStats = &issues_model.IssueStats{} + } else { + // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. + // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. + issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return } - } - isShowClosed := ctx.FormString("state") == "closed" - // if open issues are zero and close don't, use closed as default + var isShowClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isShowClosed = optional.Some(true) + case "all": + isShowClosed = optional.None[bool]() + default: + isShowClosed = optional.Some(false) + } + // if there are closed issues and no open issues, default to showing all issues if len(ctx.FormString("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 { - isShowClosed = true + isShowClosed = optional.None[bool]() } + if repo.IsTimetrackerEnabled(ctx) { + totalTrackedTime, err := issues_model.GetIssueTotalTrackedTime(ctx, statsOpts, isShowClosed) + if err != nil { + ctx.ServerError("GetIssueTotalTrackedTime", err) + return + } + ctx.Data["TotalTrackedTime"] = totalTrackedTime + } + + archived := ctx.FormBool("archived") + page := ctx.FormInt("page") if page <= 1 { page = 1 } var total int - if !isShowClosed { - total = int(issueStats.OpenCount) - } else { + switch { + case isShowClosed.Value(): total = int(issueStats.ClosedCount) + case !isShowClosed.Has(): + total = int(issueStats.OpenCount + issueStats.ClosedCount) + default: + total = int(issueStats.OpenCount) } pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) @@ -274,7 +297,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ReviewedID: reviewedID, MilestoneIDs: mileIDs, ProjectID: projectID, - IsClosed: util.OptionalBoolOf(isShowClosed), + IsClosed: isShowClosed, IsPull: isPullOption, LabelIDs: labelIDs, SortType: sortType, @@ -300,15 +323,15 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti return } - // Get posters. - for i := range issues { - // Check read status - if !ctx.IsSigned { - issues[i].IsRead = true - } else if err = issues[i].GetIsRead(ctx.Doer.ID); err != nil { - ctx.ServerError("GetIsRead", err) + if ctx.IsSigned { + if err := issues.LoadIsRead(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("LoadIsRead", err) return } + } else { + for i := range issues { + issues[i].IsRead = true + } } commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) @@ -408,7 +431,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti return } - pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.IsTrue()) + pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.Value()) if err != nil { ctx.ServerError("GetPinnedIssues", err) return @@ -417,6 +440,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ctx.Data["PinnedIssues"] = pinned ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) ctx.Data["IssueStats"] = issueStats + ctx.Data["OpenCount"] = issueStats.OpenCount + ctx.Data["ClosedCount"] = issueStats.ClosedCount + linkStr := "%s?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%d&archived=%t" + ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Link, + url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels), + milestoneID, projectID, assigneeID, posterID, archived) ctx.Data["SelLabelIDs"] = labelIDs ctx.Data["SelectLabels"] = selectLabels ctx.Data["ViewType"] = viewType @@ -425,23 +460,28 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti ctx.Data["ProjectID"] = projectID ctx.Data["AssigneeID"] = assigneeID ctx.Data["PosterID"] = posterID - ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["Keyword"] = keyword - if isShowClosed { + switch { + case isShowClosed.Value(): ctx.Data["State"] = "closed" - } else { + case !isShowClosed.Has(): + ctx.Data["State"] = "all" + default: ctx.Data["State"] = "open" } + ctx.Data["ShowArchivedLabels"] = archived + + pager.AddParamString("q", keyword) + pager.AddParamString("type", viewType) + pager.AddParamString("sort", sortType) + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) + pager.AddParamString("labels", fmt.Sprint(selectLabels)) + pager.AddParamString("milestone", fmt.Sprint(milestoneID)) + pager.AddParamString("project", fmt.Sprint(projectID)) + pager.AddParamString("assignee", fmt.Sprint(assigneeID)) + pager.AddParamString("poster", fmt.Sprint(posterID)) + pager.AddParamString("archived", fmt.Sprint(archived)) - pager.AddParam(ctx, "q", "Keyword") - pager.AddParam(ctx, "type", "ViewType") - pager.AddParam(ctx, "sort", "SortType") - pager.AddParam(ctx, "state", "State") - pager.AddParam(ctx, "labels", "SelectLabels") - pager.AddParam(ctx, "milestone", "MilestoneID") - pager.AddParam(ctx, "project", "ProjectID") - pager.AddParam(ctx, "assignee", "AssigneeID") - pager.AddParam(ctx, "poster", "PosterID") ctx.Data["Page"] = pager } @@ -473,7 +513,7 @@ func Issues(ctx *context.Context) { ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) } - issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), util.OptionalBoolOf(isPullList)) + issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) if ctx.Written() { return } @@ -490,9 +530,8 @@ func Issues(ctx *context.Context) { func renderMilestones(ctx *context.Context) { // Get milestones - milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ RepoID: ctx.Repo.Repository.ID, - State: api.StateAll, }) if err != nil { ctx.ServerError("GetAllRepoMilestones", err) @@ -514,17 +553,17 @@ func renderMilestones(ctx *context.Context) { // RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.Repository) { var err error - ctx.Data["OpenMilestones"], _, err = issues_model.GetMilestones(issues_model.GetMilestonesOption{ - RepoID: repo.ID, - State: api.StateOpen, + ctx.Data["OpenMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(false), }) if err != nil { ctx.ServerError("GetMilestones", err) return } - ctx.Data["ClosedMilestones"], _, err = issues_model.GetMilestones(issues_model.GetMilestonesOption{ - RepoID: repo.ID, - State: api.StateClosed, + ctx.Data["ClosedMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(true), }) if err != nil { ctx.ServerError("GetMilestones", err) @@ -548,52 +587,63 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { if repo.Owner.IsOrganization() { repoOwnerType = project_model.TypeOrganization } - var err error - projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ - RepoID: repo.ID, - Page: -1, - IsClosed: util.OptionalBoolFalse, - Type: project_model.TypeRepository, - }) - if err != nil { - ctx.ServerError("GetProjects", err) - return - } - projects2, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ - OwnerID: repo.OwnerID, - Page: -1, - IsClosed: util.OptionalBoolFalse, - Type: repoOwnerType, - }) - if err != nil { - ctx.ServerError("GetProjects", err) - return - } - ctx.Data["OpenProjects"] = append(projects, projects2...) + projectsUnit := repo.MustGetUnit(ctx, unit.TypeProjects) - projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ - RepoID: repo.ID, - Page: -1, - IsClosed: util.OptionalBoolTrue, - Type: project_model.TypeRepository, - }) - if err != nil { - ctx.ServerError("GetProjects", err) - return + var openProjects []*project_model.Project + var closedProjects []*project_model.Project + var err error + + if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { + openProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(false), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + RepoID: repo.ID, + IsClosed: optional.Some(true), + Type: project_model.TypeRepository, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } } - projects2, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ - OwnerID: repo.OwnerID, - Page: -1, - IsClosed: util.OptionalBoolTrue, - Type: repoOwnerType, - }) - if err != nil { - ctx.ServerError("GetProjects", err) - return + + if projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeOwner) { + openProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(false), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + openProjects = append(openProjects, openProjects2...) + closedProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: repo.OwnerID, + IsClosed: optional.Some(true), + Type: repoOwnerType, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + closedProjects = append(closedProjects, closedProjects2...) } - ctx.Data["ClosedProjects"] = append(projects, projects2...) + ctx.Data["OpenProjects"] = openProjects + ctx.Data["ClosedProjects"] = closedProjects } // repoReviewerSelection items to bee shown @@ -611,14 +661,14 @@ type repoReviewerSelection struct { func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) { ctx.Data["CanChooseReviewer"] = canChooseReviewer - originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(issue.ID) + originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) if err != nil { ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) return } ctx.Data["OriginalReviews"] = originalAuthorReviews - reviews, err := issues_model.GetReviewsByIssueID(issue.ID) + reviews, err := issues_model.GetReviewsByIssueID(ctx, issue.ID) if err != nil { ctx.ServerError("GetReviewersByIssueID", err) return @@ -676,16 +726,12 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is tmp.ItemID = -review.ReviewerTeamID } - if ctx.Repo.IsAdmin() { - // Admin can dismiss or re-request any review requests + if canChooseReviewer { + // Users who can choose reviewers can also remove review requests tmp.CanChange = true } else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest { // A user can refuse review requests tmp.CanChange = true - } else if (canChooseReviewer || (ctx.Doer != nil && ctx.Doer.ID == issue.PosterID)) && review.Type != issues_model.ReviewTypeRequest && - ctx.Doer.ID != review.ReviewerID { - // The poster of the PR, a manager, or official reviewers can re-request review from other reviewers - tmp.CanChange = true } pullReviews = append(pullReviews, tmp) @@ -825,7 +871,7 @@ func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull } // Contains true if the user can create issue dependencies - ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.Doer, isPull) + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) return labels } @@ -958,19 +1004,17 @@ func NewIssue(ctx *context.Context) { } ctx.Data["Tags"] = tags - _, templateErrs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) - if len(errs) > 0 { - for k, v := range errs { - templateErrs[k] = v - } + for k, v := range errs { + ret.TemplateErrors[k] = v } if ctx.Written() { return } - if len(templateErrs) > 0 { - ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) + if len(ret.TemplateErrors) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true) } ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) @@ -984,7 +1028,7 @@ func NewIssue(ctx *context.Context) { ctx.HTML(http.StatusOK, tplIssueNew) } -func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) string { +func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) template.HTML { var files []string for k := range errs { files = append(files, k) @@ -996,14 +1040,14 @@ func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) string lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file])) } - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"), "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)), "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")), }) if err != nil { log.Debug("render flash error: %v", err) - flashError = ctx.Tr("repo.issues.choose.ignore_invalid_templates") + flashError = ctx.Locale.Tr("repo.issues.choose.ignore_invalid_templates") } return flashError } @@ -1013,11 +1057,11 @@ func NewIssueChooseTemplate(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - issueTemplates, errs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - ctx.Data["IssueTemplates"] = issueTemplates + ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["IssueTemplates"] = ret.IssueTemplates - if len(errs) > 0 { - ctx.Flash.Warning(renderErrorOfTemplates(ctx, errs), true) + if len(ret.TemplateErrors) > 0 { + ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true) } if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) { @@ -1179,6 +1223,14 @@ func NewIssuePost(ctx *context.Context) { return } + if projectID > 0 { + if !ctx.Repo.CanRead(unit.TypeProjects) { + // User must also be able to see the project. + ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + return + } + } + if setting.Attachment.Enabled { attachments = form.Files } @@ -1211,27 +1263,17 @@ func NewIssuePost(ctx *context.Context) { Ref: form.Ref, } - if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.new.blocked_user")) + } else { + ctx.ServerError("NewIssue", err) } - ctx.ServerError("NewIssue", err) return } - if projectID > 0 { - if !ctx.Repo.CanRead(unit.TypeProjects) { - // User must also be able to see the project. - ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") - return - } - if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil { - ctx.ServerError("ChangeProjectAssign", err) - return - } - } - log.Trace("Issue created: %d/%d", repo.ID, issue.ID) if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) @@ -1265,7 +1307,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use } // Otherwise check if poster is the real repo admin. - ok, err := access_model.IsUserRealRepoAdmin(repo, poster) + ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) if err != nil { return roleDescriptor, err } @@ -1301,7 +1343,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use return roleDescriptor, err } else if hasMergedPR { roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor - } else { + } else if issue.IsPull { // only display first time contributor in the first opening pull request roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor } @@ -1329,7 +1371,7 @@ func ViewIssue(ctx *context.Context) { extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) if err == nil && extIssueUnit != nil { if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { - metas := ctx.Repo.Repository.ComposeMetas() + metas := ctx.Repo.Repository.ComposeMetas(ctx) metas["index"] = ctx.Params(":index") res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) if err != nil { @@ -1406,25 +1448,26 @@ func ViewIssue(ctx *context.Context) { return } - ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title) + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) iw := new(issues_model.IssueWatch) if ctx.Doer != nil { iw.UserID = ctx.Doer.ID iw.IssueID = issue.ID - iw.IsWatching, err = issues_model.CheckIssueWatch(ctx.Doer, issue) + iw.IsWatching, err = issues_model.CheckIssueWatch(ctx, ctx.Doer, issue) if err != nil { ctx.ServerError("CheckIssueWatch", err) return } } ctx.Data["IssueWatch"] = iw - issue.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, issue.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -1491,18 +1534,9 @@ func ViewIssue(ctx *context.Context) { } if issue.IsPull { - canChooseReviewer := ctx.Repo.CanWrite(unit.TypePullRequests) + canChooseReviewer := false if ctx.Doer != nil && ctx.IsSigned { - if !canChooseReviewer { - canChooseReviewer = ctx.Doer.ID == issue.PosterID - } - if !canChooseReviewer { - canChooseReviewer, err = issues_model.IsOfficialReviewer(ctx, issue, ctx.Doer) - if err != nil { - ctx.ServerError("IsOfficialReviewer", err) - return - } - } + canChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, issue) } RetrieveRepoReviewers(ctx, repo, issue, canChooseReviewer) @@ -1530,7 +1564,7 @@ func ViewIssue(ctx *context.Context) { if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { if ctx.IsSigned { // Deal with the stopwatch - ctx.Data["IsStopwatchRunning"] = issues_model.StopwatchExists(ctx.Doer.ID, issue.ID) + ctx.Data["IsStopwatchRunning"] = issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) if !ctx.Data["IsStopwatchRunning"].(bool) { var exists bool var swIssue *issues_model.Issue @@ -1545,18 +1579,18 @@ func ViewIssue(ctx *context.Context) { ctx.Data["OtherStopwatchURL"] = swIssue.Link() } } - ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(issue, ctx.Doer) + ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) } else { ctx.Data["CanUseTimetracker"] = false } - if ctx.Data["WorkingUsers"], err = issues_model.TotalTimes(&issues_model.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { - ctx.ServerError("TotalTimes", err) + if ctx.Data["WorkingUsers"], err = issues_model.TotalTimesForEachUser(ctx, &issues_model.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil { + ctx.ServerError("TotalTimesForEachUser", err) return } } // Check if the user can use the dependencies - ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx.Doer, issue.IsPull) + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) // check if dependencies can be created across repositories ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies @@ -1567,27 +1601,29 @@ func ViewIssue(ctx *context.Context) { } marked[issue.PosterID] = issue.ShowRole - // Render comments and and fetch participants. + // Render comments and fetch participants. participants[0] = issue.Poster + + if err := issue.Comments.LoadAttachmentsByIssue(ctx); err != nil { + ctx.ServerError("LoadAttachmentsByIssue", err) + return + } + if err := issue.Comments.LoadPosters(ctx); err != nil { + ctx.ServerError("LoadPosters", err) + return + } + for _, comment = range issue.Comments { comment.Issue = issue - if err := comment.LoadPoster(ctx); err != nil { - ctx.ServerError("LoadPoster", err) - return - } - if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview { - if err := comment.LoadAttachments(ctx); err != nil { - ctx.ServerError("LoadAttachments", err) - return - } - comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, comment.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -1608,7 +1644,7 @@ func ViewIssue(ctx *context.Context) { marked[comment.PosterID] = comment.ShowRole participants = addParticipant(comment.Poster, participants) } else if comment.Type == issues_model.CommentTypeLabel { - if err = comment.LoadLabel(); err != nil { + if err = comment.LoadLabel(ctx); err != nil { ctx.ServerError("LoadLabel", err) return } @@ -1619,7 +1655,7 @@ func ViewIssue(ctx *context.Context) { } ghostMilestone := &issues_model.Milestone{ ID: -1, - Name: ctx.Tr("repo.issues.deleted_milestone"), + Name: ctx.Locale.TrString("repo.issues.deleted_milestone"), } if comment.OldMilestoneID > 0 && comment.OldMilestone == nil { comment.OldMilestone = ghostMilestone @@ -1628,15 +1664,14 @@ func ViewIssue(ctx *context.Context) { comment.Milestone = ghostMilestone } } else if comment.Type == issues_model.CommentTypeProject { - - if err = comment.LoadProject(); err != nil { + if err = comment.LoadProject(ctx); err != nil { ctx.ServerError("LoadProject", err) return } ghostProject := &project_model.Project{ ID: -1, - Title: ctx.Tr("repo.issues.deleted_project"), + Title: ctx.Locale.TrString("repo.issues.deleted_project"), } if comment.OldProjectID > 0 && comment.OldProject == nil { @@ -1648,12 +1683,12 @@ func ViewIssue(ctx *context.Context) { } } else if comment.Type == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest { - if err = comment.LoadAssigneeUserAndTeam(); err != nil { + if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil { ctx.ServerError("LoadAssigneeUserAndTeam", err) return } } else if comment.Type == issues_model.CommentTypeRemoveDependency || comment.Type == issues_model.CommentTypeAddDependency { - if err = comment.LoadDepIssueDetails(); err != nil { + if err = comment.LoadDepIssueDetails(ctx); err != nil { if !issues_model.IsErrIssueNotExist(err) { ctx.ServerError("LoadDepIssueDetails", err) return @@ -1661,16 +1696,18 @@ func ViewIssue(ctx *context.Context) { } } else if comment.Type.HasContentSupport() { comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, comment.Content) if err != nil { ctx.ServerError("RenderString", err) return } - if err = comment.LoadReview(); err != nil && !issues_model.IsErrReviewNotExist(err) { + if err = comment.LoadReview(ctx); err != nil && !issues_model.IsErrReviewNotExist(err) { ctx.ServerError("LoadReview", err) return } @@ -1709,7 +1746,7 @@ func ViewIssue(ctx *context.Context) { } } } - if err = comment.LoadResolveDoer(); err != nil { + if err = comment.LoadResolveDoer(ctx); err != nil { ctx.ServerError("LoadResolveDoer", err) return } @@ -1723,13 +1760,13 @@ func ViewIssue(ctx *context.Context) { comment.Type == issues_model.CommentTypeStopTracking || comment.Type == issues_model.CommentTypeDeleteTimeManual { // drop error since times could be pruned from DB.. - _ = comment.LoadTime() + _ = comment.LoadTime(ctx) if comment.Content != "" { // Content before v1.21 did store the formated string instead of seconds, // so "|" is used as delimeter to mark the new format if comment.Content[0] != '|' { // handle old time comments that have formatted text stored - comment.RenderedContent = comment.Content + comment.RenderedContent = templates.SanitizeHTML(comment.Content) comment.Content = "" } else { // else it's just a duration in seconds to pass on to the frontend @@ -1755,7 +1792,7 @@ func ViewIssue(ctx *context.Context) { pull := issue.PullRequest pull.Issue = issue canDelete := false - ctx.Data["AllowMerge"] = false + allowMerge := false if ctx.IsSigned { if err := pull.LoadHeadRepo(ctx); err != nil { @@ -1788,18 +1825,20 @@ func ViewIssue(ctx *context.Context) { ctx.ServerError("GetUserRepoPermission", err) return } - ctx.Data["AllowMerge"], err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer) + allowMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer) if err != nil { ctx.ServerError("IsUserAllowedToMerge", err) return } - if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(issue, ctx.Doer); err != nil { + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { ctx.ServerError("CanMarkConversation", err) return } } + ctx.Data["AllowMerge"] = allowMerge + prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests) if err != nil { ctx.ServerError("GetUnit", err) @@ -1822,6 +1861,8 @@ func ViewIssue(ctx *context.Context) { mergeStyle = repo_model.MergeStyleRebaseMerge } else if prConfig.AllowSquash { mergeStyle = repo_model.MergeStyleSquash + } else if prConfig.AllowFastForwardOnly { + mergeStyle = repo_model.MergeStyleFastForwardOnly } else if prConfig.AllowManualMerge { mergeStyle = repo_model.MergeStyleManuallyMerged } @@ -1906,10 +1947,10 @@ func ViewIssue(ctx *context.Context) { if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { return false } - if pull.CanAutoMerge() || pull.IsWorkInProgress() || pull.IsChecking() { + if pull.CanAutoMerge() || pull.IsWorkInProgress(ctx) || pull.IsChecking() { return false } - if (ctx.Doer.IsAdmin || ctx.Repo.IsAdmin()) && prConfig.AllowManualMerge { + if allowMerge && prConfig.AllowManualMerge { return true } @@ -1943,7 +1984,7 @@ func ViewIssue(ctx *context.Context) { return } - ctx.Data["BlockingDependencies"], ctx.Data["BlockingByDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) + ctx.Data["BlockingDependencies"], ctx.Data["BlockingDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) if ctx.Written() { return } @@ -1999,43 +2040,43 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["Tags"] = tags + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + ctx.HTML(http.StatusOK, tplIssueView) } // checkBlockedByIssues return canRead and notPermitted func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { - var ( - lastRepoID int64 - lastPerm access_model.Permission - ) - for i, blocker := range blockers { + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + for _, blocker := range blockers { // Get the permissions for this repository - perm := lastPerm - if lastRepoID != blocker.Repository.ID { - if blocker.Repository.ID == ctx.Repo.Repository.ID { - perm = ctx.Repo.Permission - } else { - var err error - perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return nil, nil - } + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[blocker.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return nil, nil } - lastRepoID = blocker.Repository.ID + repoPerms[blocker.RepoID] = perm } - - // check permission - if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { - blockers[len(notPermitted)], blockers[i] = blocker, blockers[len(notPermitted)] - notPermitted = blockers[:len(notPermitted)+1] + if perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { + canRead = append(canRead, blocker) + } else { + notPermitted = append(notPermitted, blocker) } } - blockers = blockers[len(notPermitted):] - sortDependencyInfo(blockers) + sortDependencyInfo(canRead) sortDependencyInfo(notPermitted) - - return blockers, notPermitted + return canRead, notPermitted } func sortDependencyInfo(blockers []*issues_model.DependencyInfo) { @@ -2138,7 +2179,7 @@ func GetIssueInfo(ctx *context.Context) { } } - ctx.JSON(http.StatusOK, convert.ToIssue(ctx, issue)) + ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue)) } // UpdateIssueTitle change issue's title @@ -2206,7 +2247,11 @@ func UpdateIssueContent(ctx *context.Context) { } if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content")); err != nil { - ctx.ServerError("ChangeContent", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.edit.blocked_user")) + } else { + ctx.ServerError("ChangeContent", err) + } return } @@ -2219,10 +2264,12 @@ func UpdateIssueContent(ctx *context.Context) { } content, err := markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, issue.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -2261,7 +2308,7 @@ func UpdateIssueDeadline(ctx *context.Context) { deadlineUnix = timeutil.TimeStamp(deadline.Unix()) } - if err := issues_model.UpdateIssueDeadline(issue, deadlineUnix, ctx.Doer); err != nil { + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error()) return } @@ -2283,7 +2330,7 @@ func UpdateIssueMilestone(ctx *context.Context) { continue } issue.MilestoneID = milestoneID - if err := issue_service.ChangeMilestoneAssign(issue, ctx.Doer, oldMilestoneID); err != nil { + if err := issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.ServerError("ChangeMilestoneAssign", err) return } @@ -2451,6 +2498,10 @@ func UpdatePullReviewRequest(ctx *context.Context) { _, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach") if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } ctx.ServerError("ReviewRequest", err) return } @@ -2467,14 +2518,14 @@ func SearchIssues(ctx *context.Context) { return } - var isClosed util.OptionalBool + var isClosed optional.Option[bool] switch ctx.FormString("state") { case "closed": - isClosed = util.OptionalBoolTrue + isClosed = optional.Some(true) case "all": - isClosed = util.OptionalBoolNone + isClosed = optional.None[bool]() default: - isClosed = util.OptionalBoolFalse + isClosed = optional.Some(false) } var ( @@ -2487,7 +2538,7 @@ func SearchIssues(ctx *context.Context) { Private: false, AllPublic: true, TopicOnly: false, - Collaborate: util.OptionalBoolNone, + Collaborate: optional.None[bool](), // This needs to be a column that is not nil in fixtures or // MySQL will return different results when sorting by null in some cases OrderBy: db.SearchOrderByAlphabetically, @@ -2510,7 +2561,7 @@ func SearchIssues(ctx *context.Context) { opts.OwnerID = owner.ID opts.AllLimited = false opts.AllPublic = false - opts.Collaborate = util.OptionalBoolFalse + opts.Collaborate = optional.Some(false) } if ctx.FormString("team") != "" { if ctx.FormString("owner") == "" { @@ -2533,7 +2584,7 @@ func SearchIssues(ctx *context.Context) { allPublic = true opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer } - repoIDs, _, err = repo_model.SearchRepositoryIDs(opts) + repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) return @@ -2549,14 +2600,12 @@ func SearchIssues(ctx *context.Context) { keyword = "" } - var isPull util.OptionalBool + isPull := optional.None[bool]() switch ctx.FormString("type") { case "pulls": - isPull = util.OptionalBoolTrue + isPull = optional.Some(true) case "issues": - isPull = util.OptionalBoolFalse - default: - isPull = util.OptionalBoolNone + isPull = optional.Some(false) } var includedAnyLabels []int64 @@ -2588,9 +2637,9 @@ func SearchIssues(ctx *context.Context) { } } - var projectID *int64 + projectID := optional.None[int64]() if v := ctx.FormInt64("project"); v > 0 { - projectID = &v + projectID = optional.Some(v) } // this api is also used in UI, @@ -2619,28 +2668,28 @@ func SearchIssues(ctx *context.Context) { } if since != 0 { - searchOpt.UpdatedAfterUnix = &since + searchOpt.UpdatedAfterUnix = optional.Some(since) } if before != 0 { - searchOpt.UpdatedBeforeUnix = &before + searchOpt.UpdatedBeforeUnix = optional.Some(before) } if ctx.IsSigned { ctxUserID := ctx.Doer.ID if ctx.FormBool("created") { - searchOpt.PosterID = &ctxUserID + searchOpt.PosterID = optional.Some(ctxUserID) } if ctx.FormBool("assigned") { - searchOpt.AssigneeID = &ctxUserID + searchOpt.AssigneeID = optional.Some(ctxUserID) } if ctx.FormBool("mentioned") { - searchOpt.MentionID = &ctxUserID + searchOpt.MentionID = optional.Some(ctxUserID) } if ctx.FormBool("review_requested") { - searchOpt.ReviewRequestedID = &ctxUserID + searchOpt.ReviewRequestedID = optional.Some(ctxUserID) } if ctx.FormBool("reviewed") { - searchOpt.ReviewedID = &ctxUserID + searchOpt.ReviewedID = optional.Some(ctxUserID) } } @@ -2660,7 +2709,7 @@ func SearchIssues(ctx *context.Context) { } ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) } func getUserIDForFilter(ctx *context.Context, queryName string) int64 { @@ -2691,14 +2740,14 @@ func ListIssues(ctx *context.Context) { return } - var isClosed util.OptionalBool + var isClosed optional.Option[bool] switch ctx.FormString("state") { case "closed": - isClosed = util.OptionalBoolTrue + isClosed = optional.Some(true) case "all": - isClosed = util.OptionalBoolNone + isClosed = optional.None[bool]() default: - isClosed = util.OptionalBoolFalse + isClosed = optional.Some(false) } keyword := ctx.FormTrim("q") @@ -2708,7 +2757,7 @@ func ListIssues(ctx *context.Context) { var labelIDs []int64 if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { - labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted) + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return @@ -2720,7 +2769,7 @@ func ListIssues(ctx *context.Context) { for i := range part { // uses names and fall back to ids // non existent milestones are discarded - mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx.Repo.Repository.ID, part[i]) + mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) if err == nil { mileIDs = append(mileIDs, mile.ID) continue @@ -2745,19 +2794,17 @@ func ListIssues(ctx *context.Context) { } } - var projectID *int64 + projectID := optional.None[int64]() if v := ctx.FormInt64("project"); v > 0 { - projectID = &v + projectID = optional.Some(v) } - var isPull util.OptionalBool + isPull := optional.None[bool]() switch ctx.FormString("type") { case "pulls": - isPull = util.OptionalBoolTrue + isPull = optional.Some(true) case "issues": - isPull = util.OptionalBoolFalse - default: - isPull = util.OptionalBoolNone + isPull = optional.Some(false) } // FIXME: we should be more efficient here @@ -2787,10 +2834,10 @@ func ListIssues(ctx *context.Context) { SortBy: issue_indexer.SortByCreatedDesc, } if since != 0 { - searchOpt.UpdatedAfterUnix = &since + searchOpt.UpdatedAfterUnix = optional.Some(since) } if before != 0 { - searchOpt.UpdatedBeforeUnix = &before + searchOpt.UpdatedBeforeUnix = optional.Some(before) } if len(labelIDs) == 1 && labelIDs[0] == 0 { searchOpt.NoLabelOnly = true @@ -2811,13 +2858,13 @@ func ListIssues(ctx *context.Context) { } if createdByID > 0 { - searchOpt.PosterID = &createdByID + searchOpt.PosterID = optional.Some(createdByID) } if assignedByID > 0 { - searchOpt.AssigneeID = &assignedByID + searchOpt.AssigneeID = optional.Some(assignedByID) } if mentionedByID > 0 { - searchOpt.MentionID = &mentionedByID + searchOpt.MentionID = optional.Some(mentionedByID) } ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) @@ -2832,7 +2879,7 @@ func ListIssues(ctx *context.Context) { } ctx.SetTotalCountHeader(total) - ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, issues)) + ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues)) } func BatchDeleteIssues(ctx *context.Context) { @@ -3037,7 +3084,7 @@ func NewComment(ctx *context.Context) { return } } else { - if err := stopTimerIfAvailable(ctx.Doer, issue); err != nil { + if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { ctx.ServerError("CreateOrStopIssueStopwatch", err) return } @@ -3066,7 +3113,11 @@ func NewComment(ctx *context.Context) { comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) if err != nil { - ctx.ServerError("CreateIssueComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) + } else { + ctx.ServerError("CreateIssueComment", err) + } return } @@ -3086,6 +3137,11 @@ func UpdateCommentContent(ctx *context.Context) { return } + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.Error(http.StatusForbidden) return @@ -3105,7 +3161,11 @@ func UpdateCommentContent(ctx *context.Context) { return } if err = issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { - ctx.ServerError("UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) + } else { + ctx.ServerError("UpdateComment", err) + } return } @@ -3123,10 +3183,12 @@ func UpdateCommentContent(ctx *context.Context) { } content, err := markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, comment.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -3152,6 +3214,11 @@ func DeleteComment(ctx *context.Context) { return } + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.Error(http.StatusForbidden) return @@ -3206,9 +3273,9 @@ func ChangeIssueReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Content) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeIssueReaction", err) return } @@ -3224,7 +3291,7 @@ func ChangeIssueReaction(ctx *context.Context) { log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) case "unreact": - if err := issues_model.DeleteIssueReaction(ctx.Doer.ID, issue.ID, form.Content); err != nil { + if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil { ctx.ServerError("DeleteIssueReaction", err) return } @@ -3250,7 +3317,7 @@ func ChangeIssueReaction(ctx *context.Context) { return } - html, err := ctx.RenderToString(tplReactions, map[string]any{ + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ "ctxData": ctx.Data, "ActionURL": fmt.Sprintf("%s/issues/%d/reactions", ctx.Repo.RepoLink, issue.Index), "Reactions": issue.Reactions.GroupByType(), @@ -3278,6 +3345,11 @@ func ChangeCommentReaction(ctx *context.Context) { return } + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull)) { if log.IsTrace() { if ctx.IsSigned { @@ -3308,9 +3380,9 @@ func ChangeCommentReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeIssueReaction", err) return } @@ -3319,21 +3391,21 @@ func ChangeCommentReaction(ctx *context.Context) { } // Reload new reactions comment.Reactions = nil - if err = comment.LoadReactions(ctx.Repo.Repository); err != nil { + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { log.Info("comment.LoadReactions: %s", err) break } log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID, reaction.ID) case "unreact": - if err := issues_model.DeleteCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content); err != nil { + if err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content); err != nil { ctx.ServerError("DeleteCommentReaction", err) return } // Reload new reactions comment.Reactions = nil - if err = comment.LoadReactions(ctx.Repo.Repository); err != nil { + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { log.Info("comment.LoadReactions: %s", err) break } @@ -3352,7 +3424,7 @@ func ChangeCommentReaction(ctx *context.Context) { return } - html, err := ctx.RenderToString(tplReactions, map[string]any{ + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ "ctxData": ctx.Data, "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), "Reactions": comment.Reactions.GroupByType(), @@ -3421,6 +3493,21 @@ func GetCommentAttachments(ctx *context.Context) { return } + if err := comment.LoadIssue(ctx); err != nil { + ctx.NotFoundOrServerError("LoadIssue", issues_model.IsErrIssueNotExist, err) + return + } + + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } + + if !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound("CanReadIssuesOrPulls", issues_model.ErrCommentNotExist{}) + return + } + if !comment.Type.HasAttachmentSupport() { ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type)) return @@ -3459,9 +3546,9 @@ func updateAttachments(ctx *context.Context, item any, files []string) error { if len(files) > 0 { switch content := item.(type) { case *issues_model.Issue: - err = issues_model.UpdateIssueAttachments(content.ID, files) + err = issues_model.UpdateIssueAttachments(ctx, content.ID, files) case *issues_model.Comment: - err = content.UpdateAttachments(files) + err = content.UpdateAttachments(ctx, files) default: return fmt.Errorf("unknown Type: %T", content) } @@ -3480,8 +3567,8 @@ func updateAttachments(ctx *context.Context, item any, files []string) error { return err } -func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment, content string) string { - attachHTML, err := ctx.RenderToString(tplAttachment, map[string]any{ +func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment, content string) template.HTML { + attachHTML, err := ctx.RenderToHTML(tplAttachment, map[string]any{ "ctxData": ctx.Data, "Attachments": attachments, "Content": content, @@ -3574,7 +3661,7 @@ func handleTeamMentions(ctx *context.Context) { if ctx.Doer.IsAdmin { isAdmin = true } else { - isAdmin, err = org.IsOwnedBy(ctx.Doer.ID) + isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("IsOwnedBy", err) return @@ -3582,13 +3669,13 @@ func handleTeamMentions(ctx *context.Context) { } if isAdmin { - teams, err = org.LoadTeams() + teams, err = org.LoadTeams(ctx) if err != nil { ctx.ServerError("LoadTeams", err) return } } else { - teams, err = org.GetUserTeams(ctx.Doer.ID) + teams, err = org.GetUserTeams(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetUserTeams", err) return diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go index 3dd7725c215..bf3571c835c 100644 --- a/routers/web/repo/issue_content_history.go +++ b/routers/web/repo/issue_content_history.go @@ -11,12 +11,11 @@ import ( "code.gitea.io/gitea/models/avatars" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/context" "github.com/sergi/go-diff/diffmatchpatch" ) @@ -57,12 +56,12 @@ func GetContentHistoryList(ctx *context.Context) { for _, item := range items { var actionText string if item.IsDeleted { - actionTextDeleted := ctx.Locale.Tr("repo.issues.content_history.deleted") + actionTextDeleted := ctx.Locale.TrString("repo.issues.content_history.deleted") actionText = "" + actionTextDeleted + "" } else if item.IsFirstCreated { - actionText = ctx.Locale.Tr("repo.issues.content_history.created") + actionText = ctx.Locale.TrString("repo.issues.content_history.created") } else { - actionText = ctx.Locale.Tr("repo.issues.content_history.edited") + actionText = ctx.Locale.TrString("repo.issues.content_history.edited") } username := item.UserName @@ -71,7 +70,7 @@ func GetContentHistoryList(ctx *context.Context) { } src := html.EscapeString(item.UserAvatarLink) - class := avatars.DefaultAvatarClass + " gt-mr-3" + class := avatars.DefaultAvatarClass + " tw-mr-2" name := html.EscapeString(username) avatarHTML := string(templates.AvatarHTML(src, 28, class, username)) timeSinceText := string(timeutil.TimeSinceUnix(item.EditedUnix, ctx.Locale)) @@ -91,11 +90,16 @@ func GetContentHistoryList(ctx *context.Context) { // Admins or owners can always delete history revisions. Normal users can only delete own history revisions. func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue, comment *issues_model.Comment, history *issues_model.ContentHistory, -) bool { - canSoftDelete := false - if ctx.Repo.IsOwner() { +) (canSoftDelete bool) { + // CanWrite means the doer can manage the issue/PR list + if ctx.Repo.IsOwner() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { canSoftDelete = true - } else if ctx.Repo.CanWrite(unit.TypeIssues) { + } else if ctx.Doer != nil { + // for read-only users, they could still post issues or comments, + // they should be able to delete the history related to their own issue/comment, a case is: + // 1. the user posts some sensitive data + // 2. then the repo owner edits the post but didn't remove the sensitive data + // 3. the poster wants to delete the edited history revision if comment == nil { // the issue poster or the history poster can soft-delete canSoftDelete = ctx.Doer.ID == issue.PosterID || ctx.Doer.ID == history.PosterID @@ -118,7 +122,7 @@ func GetContentHistoryDetail(ctx *context.Context) { } historyID := ctx.FormInt64("history_id") - history, prevHistory, err := issues_model.GetIssueContentHistoryAndPrev(ctx, historyID) + history, prevHistory, err := issues_model.GetIssueContentHistoryAndPrev(ctx, issue.ID, historyID) if err != nil { ctx.JSON(http.StatusNotFound, map[string]any{ "message": "Can not find the content history", @@ -182,6 +186,10 @@ func SoftDeleteContentHistory(ctx *context.Context) { if ctx.Written() { return } + if ctx.Doer == nil { + ctx.NotFound("Require SignIn", nil) + return + } commentID := ctx.FormInt64("comment_id") historyID := ctx.FormInt64("history_id") @@ -189,15 +197,29 @@ func SoftDeleteContentHistory(ctx *context.Context) { var comment *issues_model.Comment var history *issues_model.ContentHistory var err error + + if history, err = issues_model.GetIssueContentHistoryByID(ctx, historyID); err != nil { + log.Error("can not get issue content history %v. err=%v", historyID, err) + return + } + if history.IssueID != issue.ID { + ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + return + } if commentID != 0 { + if history.CommentID != commentID { + ctx.NotFound("CompareCommentID", issues_model.ErrCommentNotExist{}) + return + } + if comment, err = issues_model.GetCommentByID(ctx, commentID); err != nil { log.Error("can not get comment for issue content history %v. err=%v", historyID, err) return } - } - if history, err = issues_model.GetIssueContentHistoryByID(ctx, historyID); err != nil { - log.Error("can not get issue content history %v. err=%v", historyID, err) - return + if comment.IssueID != issue.ID { + ctx.NotFound("CompareIssueID", issues_model.ErrCommentNotExist{}) + return + } } canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history) diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go index 5b9c570b74f..e3b85ee638e 100644 --- a/routers/web/repo/issue_dependency.go +++ b/routers/web/repo/issue_dependency.go @@ -8,8 +8,8 @@ import ( issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) // AddDependency adds new dependencies @@ -22,7 +22,7 @@ func AddDependency(ctx *context.Context) { } // Check if the Repo is allowed to have dependencies - if !ctx.Repo.CanCreateIssueDependencies(ctx.Doer, issue.IsPull) { + if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") return } @@ -72,7 +72,7 @@ func AddDependency(ctx *context.Context) { return } - err = issues_model.CreateIssueDependency(ctx.Doer, issue, dep) + err = issues_model.CreateIssueDependency(ctx, ctx.Doer, issue, dep) if err != nil { if issues_model.IsErrDependencyExists(err) { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_exists")) @@ -80,10 +80,9 @@ func AddDependency(ctx *context.Context) { } else if issues_model.IsErrCircularDependency(err) { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_cannot_create_circular")) return - } else { - ctx.ServerError("CreateOrUpdateIssueDependency", err) - return } + ctx.ServerError("CreateOrUpdateIssueDependency", err) + return } } @@ -97,7 +96,7 @@ func RemoveDependency(ctx *context.Context) { } // Check if the Repo is allowed to have dependencies - if !ctx.Repo.CanCreateIssueDependencies(ctx.Doer, issue.IsPull) { + if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") return } @@ -131,7 +130,7 @@ func RemoveDependency(ctx *context.Context) { return } - if err = issues_model.RemoveIssueDependency(ctx.Doer, issue, dep, depType); err != nil { + if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil { if issues_model.IsErrDependencyNotExists(err) { ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist")) return diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index 257610d3af5..81bee4dbb53 100644 --- a/routers/web/repo/issue_label.go +++ b/routers/web/repo/issue_label.go @@ -10,12 +10,11 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" issue_service "code.gitea.io/gitea/services/issue" ) @@ -85,7 +84,7 @@ func RetrieveLabels(ctx *context.Context) { return } if ctx.Doer != nil { - ctx.Org.IsOwner, err = org.IsOwnedBy(ctx.Doer.ID) + ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("org.IsOwnedBy", err) return @@ -112,12 +111,11 @@ func NewLabel(ctx *context.Context) { } l := &issues_model.Label{ - RepoID: ctx.Repo.Repository.ID, - Name: form.Title, - Exclusive: form.Exclusive, - Description: form.Description, - Color: form.Color, - ArchivedUnix: timeutil.TimeStamp(0), + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, } if err := issues_model.NewLabel(ctx, l); err != nil { ctx.ServerError("NewLabel", err) @@ -145,7 +143,7 @@ func UpdateLabel(ctx *context.Context) { l.Color = form.Color l.SetArchived(form.IsArchived) - if err := issues_model.UpdateLabel(l); err != nil { + if err := issues_model.UpdateLabel(ctx, l); err != nil { ctx.ServerError("UpdateLabel", err) return } @@ -154,7 +152,7 @@ func UpdateLabel(ctx *context.Context) { // DeleteLabel delete a label func DeleteLabel(ctx *context.Context) { - if err := issues_model.DeleteLabel(ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteLabel: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.issues.label_deletion_success")) @@ -173,7 +171,7 @@ func UpdateIssueLabel(ctx *context.Context) { switch action := ctx.FormString("action"); action { case "clear": for _, issue := range issues { - if err := issue_service.ClearLabels(issue, ctx.Doer); err != nil { + if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil { ctx.ServerError("ClearLabels", err) return } @@ -208,14 +206,14 @@ func UpdateIssueLabel(ctx *context.Context) { if action == "attach" { for _, issue := range issues { - if err = issue_service.AddLabel(issue, ctx.Doer, label); err != nil { + if err = issue_service.AddLabel(ctx, issue, ctx.Doer, label); err != nil { ctx.ServerError("AddLabel", err) return } } } else { for _, issue := range issues { - if err = issue_service.RemoveLabel(issue, ctx.Doer, label); err != nil { + if err = issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil { ctx.ServerError("RemoveLabel", err) return } diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go index e0d49e44e18..93fc72300b3 100644 --- a/routers/web/repo/issue_label_test.go +++ b/routers/web/repo/issue_label_test.go @@ -10,10 +10,10 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" @@ -123,7 +123,7 @@ func TestDeleteLabel(t *testing.T) { assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: 2}) unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{LabelID: 2}) - assert.Equal(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) + assert.EqualValues(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) } func TestUpdateIssueLabel_Clear(t *testing.T) { diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go index 93f5a588d91..1d5fc8a5f39 100644 --- a/routers/web/repo/issue_lock.go +++ b/routers/web/repo/issue_lock.go @@ -5,8 +5,8 @@ package repo import ( issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -29,7 +29,7 @@ func LockIssue(ctx *context.Context) { return } - if err := issues_model.LockIssue(&issues_model.IssueLockOptions{ + if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ Doer: ctx.Doer, Issue: issue, Reason: form.Reason, @@ -53,7 +53,7 @@ func UnlockIssue(ctx *context.Context) { return } - if err := issues_model.UnlockIssue(&issues_model.IssueLockOptions{ + if err := issues_model.UnlockIssue(ctx, &issues_model.IssueLockOptions{ Doer: ctx.Doer, Issue: issue, }); err != nil { diff --git a/routers/web/repo/issue_pin.go b/routers/web/repo/issue_pin.go index f853f72335e..365c812681a 100644 --- a/routers/web/repo/issue_pin.go +++ b/routers/web/repo/issue_pin.go @@ -7,9 +7,9 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) // IssuePinOrUnpin pin or unpin a Issue @@ -90,6 +90,12 @@ func IssuePinMove(ctx *context.Context) { return } + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + log.Error("Issue does not belong to this repository") + return + } + err = issue.MovePin(ctx, form.Position) if err != nil { ctx.Status(http.StatusInternalServerError) diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index 3e715437e6c..70d42b27c0a 100644 --- a/routers/web/repo/issue_stopwatch.go +++ b/routers/web/repo/issue_stopwatch.go @@ -9,8 +9,8 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/eventsource" + "code.gitea.io/gitea/services/context" ) // IssueStopwatch creates or stops a stopwatch for the given issue. @@ -22,16 +22,16 @@ func IssueStopwatch(c *context.Context) { var showSuccessMessage bool - if !issues_model.StopwatchExists(c.Doer.ID, issue.ID) { + if !issues_model.StopwatchExists(c, c.Doer.ID, issue.ID) { showSuccessMessage = true } - if !c.Repo.CanUseTimetracker(issue, c.Doer) { + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { c.NotFound("CanUseTimetracker", nil) return } - if err := issues_model.CreateOrStopIssueStopwatch(c.Doer, issue); err != nil { + if err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { c.ServerError("CreateOrStopIssueStopwatch", err) return } @@ -50,17 +50,17 @@ func CancelStopwatch(c *context.Context) { if c.Written() { return } - if !c.Repo.CanUseTimetracker(issue, c.Doer) { + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { c.NotFound("CanUseTimetracker", nil) return } - if err := issues_model.CancelStopwatch(c.Doer, issue); err != nil { + if err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { c.ServerError("CancelStopwatch", err) return } - stopwatches, err := issues_model.GetUserStopwatches(c.Doer.ID, db.ListOptions{}) + stopwatches, err := issues_model.GetUserStopwatches(c, c.Doer.ID, db.ListOptions{}) if err != nil { c.ServerError("GetUserStopwatches", err) return diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go index 04ca65dd9a1..241e4340499 100644 --- a/routers/web/repo/issue_timetrack.go +++ b/routers/web/repo/issue_timetrack.go @@ -9,9 +9,9 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -22,7 +22,7 @@ func AddTimeManually(c *context.Context) { if c.Written() { return } - if !c.Repo.CanUseTimetracker(issue, c.Doer) { + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { c.NotFound("CanUseTimetracker", nil) return } @@ -56,12 +56,12 @@ func DeleteTime(c *context.Context) { if c.Written() { return } - if !c.Repo.CanUseTimetracker(issue, c.Doer) { + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { c.NotFound("CanUseTimetracker", nil) return } - t, err := issues_model.GetTrackedTimeByID(c.ParamsInt64(":timeid")) + t, err := issues_model.GetTrackedTimeByID(c, c.ParamsInt64(":timeid")) if err != nil { if db.IsErrNotExist(err) { c.NotFound("time not found", err) @@ -77,7 +77,7 @@ func DeleteTime(c *context.Context) { return } - if err = issues_model.DeleteTime(t); err != nil { + if err = issues_model.DeleteTime(c, t); err != nil { c.ServerError("DeleteTime", err) return } diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go index d3d3a2af21e..8b033f3b17a 100644 --- a/routers/web/repo/issue_watch.go +++ b/routers/web/repo/issue_watch.go @@ -8,8 +8,13 @@ import ( "strconv" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +const ( + tplWatching base.TplName = "repo/issue/view_content/watching" ) // IssueWatch sets issue watching @@ -47,10 +52,12 @@ func IssueWatch(ctx *context.Context) { return } - if err := issues_model.CreateOrUpdateIssueWatch(ctx.Doer.ID, issue.ID, watch); err != nil { + if err := issues_model.CreateOrUpdateIssueWatch(ctx, ctx.Doer.ID, issue.ID, watch); err != nil { ctx.ServerError("CreateOrUpdateIssueWatch", err) return } - ctx.Redirect(issue.Link()) + ctx.Data["Issue"] = issue + ctx.Data["IssueWatch"] = &issues_model.IssueWatch{IsWatching: watch} + ctx.HTML(http.StatusOK, tplWatching) } diff --git a/routers/web/repo/main_test.go b/routers/web/repo/main_test.go index e3a09a95bd3..6e469cf2ed7 100644 --- a/routers/web/repo/main_test.go +++ b/routers/web/repo/main_test.go @@ -4,14 +4,11 @@ package repo import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go index b50e96be3c4..420931c5fb7 100644 --- a/routers/web/repo/middlewares.go +++ b/routers/web/repo/middlewares.go @@ -9,14 +9,15 @@ import ( system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) // SetEditorconfigIfExists set editor config as render variable func SetEditorconfigIfExists(ctx *context.Context) { if ctx.Repo.Repository.IsEmpty { - ctx.Data["Editorconfig"] = nil return } @@ -56,8 +57,12 @@ func SetDiffViewStyle(ctx *context.Context) { } ctx.Data["IsSplitStyle"] = style == "split" - if err := user_model.UpdateUserDiffViewStyle(ctx, ctx.Doer, style); err != nil { - ctx.ServerError("ErrUpdateDiffViewStyle", err) + + opts := &user_service.UpdateOptions{ + DiffViewStyle: optional.Some(style), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) } } @@ -93,23 +98,15 @@ func SetWhitespaceBehavior(ctx *context.Context) { // SetShowOutdatedComments set the show outdated comments option as context variable func SetShowOutdatedComments(ctx *context.Context) { showOutdatedCommentsValue := ctx.FormString("show-outdated") - // var showOutdatedCommentsValue string - if showOutdatedCommentsValue != "true" && showOutdatedCommentsValue != "false" { // invalid or no value for this form string -> use default or stored user setting + showOutdatedCommentsValue = "true" if ctx.IsSigned { - showOutdatedCommentsValue, _ = user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, "false") - } else { - // not logged in user -> use the default value - showOutdatedCommentsValue = "false" + showOutdatedCommentsValue, _ = user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, showOutdatedCommentsValue) } - } else { + } else if ctx.IsSigned { // valid value -> update user setting if user is logged in - if ctx.IsSigned { - _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, showOutdatedCommentsValue) - } + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyShowOutdatedComments, showOutdatedCommentsValue) } - - showOutdatedComments, _ := strconv.ParseBool(showOutdatedCommentsValue) - ctx.Data["ShowOutdatedComments"] = showOutdatedComments + ctx.Data["ShowOutdatedComments"], _ = strconv.ParseBool(showOutdatedCommentsValue) } diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index a6125a1a581..97b0c425ea3 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -15,13 +15,13 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" "code.gitea.io/gitea/services/task" @@ -232,13 +232,13 @@ func MigratePost(ctx *context.Context) { opts.Releases = false } - err = repo_model.CheckCreateRepository(ctx.Doer, ctxUser, opts.RepoName, false) + err = repo_model.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false) if err != nil { handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) return } - err = task.MigrateRepository(ctx.Doer, ctxUser, opts) + err = task.MigrateRepository(ctx, ctx.Doer, ctxUser, opts) if err == nil { ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(opts.RepoName)) return @@ -260,7 +260,7 @@ func setMigrationContextData(ctx *context.Context, serviceType structs.GitServic } func MigrateRetryPost(ctx *context.Context) { - if err := task.RetryMigrateTask(ctx.Repo.Repository.ID); err != nil { + if err := task.RetryMigrateTask(ctx, ctx.Repo.Repository.ID); err != nil { log.Error("Retry task failed: %v", err) ctx.ServerError("task.RetryMigrateTask", err) return @@ -269,7 +269,7 @@ func MigrateRetryPost(ctx *context.Context) { } func MigrateCancelPost(ctx *context.Context) { - migratingTask, err := admin_model.GetMigratingTask(ctx.Repo.Repository.ID) + migratingTask, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) if err != nil { log.Error("GetMigratingTask: %v", err) ctx.Redirect(ctx.Repo.Repository.Link()) @@ -277,7 +277,7 @@ func MigrateCancelPost(ctx *context.Context) { } if migratingTask.Status == structs.TaskStatusRunning { taskUpdate := &admin_model.Task{ID: migratingTask.ID, Status: structs.TaskStatusFailed, Message: "canceled"} - if err = taskUpdate.UpdateCols("status", "message"); err != nil { + if err = taskUpdate.UpdateCols(ctx, "status", "message"); err != nil { ctx.ServerError("task.UpdateCols", err) return } diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index ad355ce5d7d..95a4fe60cc1 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -4,6 +4,7 @@ package repo import ( + "fmt" "net/http" "net/url" "time" @@ -11,14 +12,13 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/issue" @@ -45,18 +45,13 @@ func Milestones(ctx *context.Context) { page = 1 } - state := structs.StateOpen - if isShowClosed { - state = structs.StateClosed - } - - miles, total, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ + miles, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ ListOptions: db.ListOptions{ Page: page, PageSize: setting.UI.IssuePagingNum, }, RepoID: ctx.Repo.Repository.ID, - State: state, + IsClosed: optional.Some(isShowClosed), SortType: sortType, Name: keyword, }) @@ -65,26 +60,33 @@ func Milestones(ctx *context.Context) { return } - stats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(builder.And(builder.Eq{"id": ctx.Repo.Repository.ID}), keyword) + stats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, builder.And(builder.Eq{"id": ctx.Repo.Repository.ID}), keyword) if err != nil { ctx.ServerError("GetMilestoneStats", err) return } ctx.Data["OpenCount"] = stats.OpenCount ctx.Data["ClosedCount"] = stats.ClosedCount + linkStr := "%s/milestones?state=%s&q=%s&sort=%s" + ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "open", + url.QueryEscape(keyword), url.QueryEscape(sortType)) + ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "closed", + url.QueryEscape(keyword), url.QueryEscape(sortType)) if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { - if err := miles.LoadTotalTrackedTimes(); err != nil { + if err := issues_model.MilestoneList(miles).LoadTotalTrackedTimes(ctx); err != nil { ctx.ServerError("LoadTotalTrackedTimes", err) return } } for _, m := range miles { m.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, m.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -104,8 +106,8 @@ func Milestones(ctx *context.Context) { ctx.Data["IsShowClosed"] = isShowClosed pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, 5) - pager.AddParam(ctx, "state", "State") - pager.AddParam(ctx, "q", "Keyword") + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) + pager.AddParamString("q", keyword) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplMilestone) @@ -142,7 +144,7 @@ func NewMilestonePost(ctx *context.Context) { } deadline = time.Date(deadline.Year(), deadline.Month(), deadline.Day(), 23, 59, 59, 0, deadline.Location()) - if err = issues_model.NewMilestone(&issues_model.Milestone{ + if err = issues_model.NewMilestone(ctx, &issues_model.Milestone{ RepoID: ctx.Repo.Repository.ID, Name: form.Title, Content: form.Content, @@ -214,7 +216,7 @@ func EditMilestonePost(ctx *context.Context) { m.Name = form.Title m.Content = form.Content m.DeadlineUnix = timeutil.TimeStamp(deadline.Unix()) - if err = issues_model.UpdateMilestone(m, m.IsClosed); err != nil { + if err = issues_model.UpdateMilestone(ctx, m, m.IsClosed); err != nil { ctx.ServerError("UpdateMilestone", err) return } @@ -225,18 +227,19 @@ func EditMilestonePost(ctx *context.Context) { // ChangeMilestoneStatus response for change a milestone's status func ChangeMilestoneStatus(ctx *context.Context) { - toClose := false + var toClose bool switch ctx.Params(":action") { case "open": toClose = false case "close": toClose = true default: - ctx.Redirect(ctx.Repo.RepoLink + "/milestones") + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones") + return } id := ctx.ParamsInt64(":id") - if err := issues_model.ChangeMilestoneStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if err := issues_model.ChangeMilestoneStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { if issues_model.IsErrMilestoneNotExist(err) { ctx.NotFound("", err) } else { @@ -244,12 +247,12 @@ func ChangeMilestoneStatus(ctx *context.Context) { } return } - ctx.Redirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.Params(":action"))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.Params(":action"))) } // DeleteMilestone delete a milestone func DeleteMilestone(ctx *context.Context) { - if err := issues_model.DeleteMilestoneByRepoID(ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success")) @@ -274,10 +277,12 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { } milestone.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, milestone.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -287,10 +292,10 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone - issues(ctx, milestoneID, projectID, util.OptionalBoolNone) + issues(ctx, milestoneID, projectID, optional.None[bool]()) - ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - ctx.Data["NewIssueChooseTemplate"] = len(ret) > 0 + ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0 ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) diff --git a/routers/web/repo/packages.go b/routers/web/repo/packages.go index ac9e64d774e..57e578da373 100644 --- a/routers/web/repo/packages.go +++ b/routers/web/repo/packages.go @@ -10,9 +10,9 @@ import ( "code.gitea.io/gitea/models/packages" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -37,7 +37,7 @@ func Packages(ctx *context.Context) { RepoID: ctx.Repo.Repository.ID, Type: packages.Type(packageType), Name: packages.SearchValue{Value: query}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { ctx.ServerError("SearchLatestVersions", err) @@ -70,8 +70,8 @@ func Packages(ctx *context.Context) { ctx.Data["RepositoryAccessMap"] = map[int64]bool{ctx.Repo.Repository.ID: true} // There is only the current repository pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) - pager.AddParam(ctx, "q", "Query") - pager.AddParam(ctx, "type", "PackageType") + pager.AddParamString("q", query) + pager.AddParamString("type", packageType) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplPackagesList) diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go index 5faf9f4fa9b..0dee02dd9ca 100644 --- a/routers/web/repo/patch.go +++ b/routers/web/repo/patch.go @@ -10,10 +10,10 @@ import ( git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/repository/files" ) @@ -79,7 +79,7 @@ func NewDiffPatchPost(ctx *context.Context) { // `message` will be both the summary and message combined message := strings.TrimSpace(form.CommitSummary) if len(message) == 0 { - message = ctx.Tr("repo.editor.patch") + message = ctx.Locale.TrString("repo.editor.patch") } form.CommitMessage = strings.TrimSpace(form.CommitMessage) @@ -104,10 +104,9 @@ func NewDiffPatchPost(ctx *context.Context) { } else if models.IsErrCommitIDDoesNotMatch(err) { ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) return - } else { - ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) - return } + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) + return } if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index eef57f46272..a2db1fc770a 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -10,19 +10,20 @@ import ( "net/url" "strings" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/perm" project_model "code.gitea.io/gitea/models/project" - attachment_model "code.gitea.io/gitea/models/repo" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -32,16 +33,17 @@ const ( tplProjectsView base.TplName = "repo/projects/view" ) -// MustEnableProjects check if projects are enabled in settings -func MustEnableProjects(ctx *context.Context) { +// MustEnableRepoProjects check if repo projects are enabled in settings +func MustEnableRepoProjects(ctx *context.Context) { if unit.TypeProjects.UnitGlobalDisabled() { ctx.NotFound("EnableKanbanBoard", nil) return } if ctx.Repo.Repository != nil { - if !ctx.Repo.CanRead(unit.TypeProjects) { - ctx.NotFound("MustEnableProjects", nil) + projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects) + if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { + ctx.NotFound("MustEnableRepoProjects", nil) return } } @@ -71,10 +73,13 @@ func Projects(ctx *context.Context) { total = repo.NumClosedProjects } - projects, count, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + projects, count, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.IssuePagingNum, + Page: page, + }, RepoID: repo.ID, - Page: page, - IsClosed: util.OptionalBoolOf(isShowClosed), + IsClosed: optional.Some(isShowClosed), OrderBy: project_model.GetSearchOrderByBySortType(sortType), Type: project_model.TypeRepository, Title: keyword, @@ -86,10 +91,12 @@ func Projects(ctx *context.Context) { for i := range projects { projects[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, projects[i].Description) if err != nil { ctx.ServerError("RenderString", err) @@ -111,7 +118,7 @@ func Projects(ctx *context.Context) { } pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, numPages) - pager.AddParam(ctx, "state", "State") + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) ctx.Data["Page"] = pager ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) @@ -142,7 +149,7 @@ func NewProjectPost(ctx *context.Context) { return } - if err := project_model.NewProject(&project_model.Project{ + if err := project_model.NewProject(ctx, &project_model.Project{ RepoID: ctx.Repo.Repository.ID, Title: form.Title, Description: form.Content, @@ -161,18 +168,19 @@ func NewProjectPost(ctx *context.Context) { // ChangeProjectStatus updates the status of a project between "open" and "close" func ChangeProjectStatus(ctx *context.Context) { - toClose := false + var toClose bool switch ctx.Params(":action") { case "open": toClose = false case "close": toClose = true default: - ctx.Redirect(ctx.Repo.RepoLink + "/projects") + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects") + return } id := ctx.ParamsInt64(":id") - if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { if project_model.IsErrProjectNotExist(err) { ctx.NotFound("", err) } else { @@ -180,7 +188,7 @@ func ChangeProjectStatus(ctx *context.Context) { } return } - ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action"))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action"))) } // DeleteProject delete a project @@ -279,7 +287,7 @@ func EditProjectPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) if ctx.FormString("redirect") == "project" { - ctx.Redirect(p.Link()) + ctx.Redirect(p.Link(ctx)) } else { ctx.Redirect(ctx.Repo.RepoLink + "/projects") } @@ -307,10 +315,6 @@ func ViewProject(ctx *context.Context) { return } - if boards[0].ID == 0 { - boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") - } - issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) if err != nil { ctx.ServerError("LoadIssuesOfBoards", err) @@ -318,10 +322,10 @@ func ViewProject(ctx *context.Context) { } if project.CardType != project_model.CardTypeTextOnly { - issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + issuesAttachmentMap := make(map[int64][]*repo_model.Attachment) for _, issuesList := range issuesMap { for _, issue := range issuesList { - if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { issuesAttachmentMap[issue.ID] = issueAttachment } } @@ -332,17 +336,17 @@ func ViewProject(ctx *context.Context) { linkedPrsMap := make(map[int64][]*issues_model.Issue) for _, issuesList := range issuesMap { for _, issue := range issuesList { - var referencedIds []int64 + var referencedIDs []int64 for _, comment := range issue.Comments { if comment.RefIssueID != 0 && comment.RefIsPull { - referencedIds = append(referencedIds, comment.RefIssueID) + referencedIDs = append(referencedIDs, comment.RefIssueID) } } - if len(referencedIds) > 0 { + if len(referencedIDs) > 0 { if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ - IssueIDs: referencedIds, - IsPull: util.OptionalBoolTrue, + IssueIDs: referencedIDs, + IsPull: optional.Some(true), }); err == nil { linkedPrsMap[issue.ID] = linkedPrs } @@ -352,10 +356,12 @@ func ViewProject(ctx *context.Context) { ctx.Data["LinkedPRs"] = linkedPrsMap project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, project.Description) if err != nil { ctx.ServerError("RenderString", err) @@ -391,7 +397,7 @@ func UpdateIssueProject(ctx *context.Context) { } } - if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil { + if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { ctx.ServerError("ChangeProjectAssign", err) return } @@ -445,7 +451,7 @@ func DeleteProjectBoard(ctx *context.Context) { return } - if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { ctx.ServerError("DeleteProjectBoardByID", err) return } @@ -463,7 +469,7 @@ func AddBoardToProjectPost(ctx *context.Context) { return } - project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.NotFound("", nil) @@ -473,7 +479,7 @@ func AddBoardToProjectPost(ctx *context.Context) { return } - if err := project_model.NewBoard(&project_model.Board{ + if err := project_model.NewBoard(ctx, &project_model.Board{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -565,22 +571,7 @@ func SetDefaultProjectBoard(ctx *context.Context) { return } - if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil { - ctx.ServerError("SetDefaultBoard", err) - return - } - - ctx.JSONOK() -} - -// UnSetDefaultProjectBoard unset default board for uncategorized issues/pulls -func UnSetDefaultProjectBoard(ctx *context.Context) { - project, _ := checkProjectBoardChangePermissions(ctx) - if ctx.Written() { - return - } - - if err := project_model.SetDefaultBoard(project.ID, 0); err != nil { + if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { ctx.ServerError("SetDefaultBoard", err) return } @@ -618,28 +609,19 @@ func MoveIssues(ctx *context.Context) { return } - var board *project_model.Board - - if ctx.ParamsInt64(":boardID") == 0 { - board = &project_model.Board{ - ID: 0, - ProjectID: project.ID, - Title: ctx.Tr("repo.projects.type.uncategorized"), - } - } else { - board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) - if err != nil { - if project_model.IsErrProjectBoardNotExist(err) { - ctx.NotFound("ProjectBoardNotExist", nil) - } else { - ctx.ServerError("GetProjectBoard", err) - } - return - } - if board.ProjectID != project.ID { - ctx.NotFound("BoardNotInProject", nil) - return + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) } + return + } + + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) + return } type movedIssuesForm struct { @@ -682,7 +664,7 @@ func MoveIssues(ctx *context.Context) { } } - if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil { + if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { ctx.ServerError("MoveIssuesOnProjectBoard", err) return } diff --git a/routers/web/repo/projects_test.go b/routers/web/repo/projects_test.go index 6698d47028b..479f8c55a22 100644 --- a/routers/web/repo/projects_test.go +++ b/routers/web/repo/projects_test.go @@ -7,7 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 0ef4a29f0c9..a0a8e5410cf 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -10,7 +10,6 @@ import ( "fmt" "html" "net/http" - "net/url" "strconv" "strings" "time" @@ -20,36 +19,36 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/automerge" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/gitdiff" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" "github.com/gobwas/glob" ) const ( - tplFork base.TplName = "repo/pulls/fork" tplCompareDiff base.TplName = "repo/diff/compare" tplPullCommits base.TplName = "repo/pulls/commits" tplPullFiles base.TplName = "repo/pulls/files" @@ -108,197 +107,6 @@ func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { return repo } -func getForkRepository(ctx *context.Context) *repo_model.Repository { - forkRepo := getRepository(ctx, ctx.ParamsInt64(":repoid")) - if ctx.Written() { - return nil - } - - if forkRepo.IsEmpty { - log.Trace("Empty repository %-v", forkRepo) - ctx.NotFound("getForkRepository", nil) - return nil - } - - if err := forkRepo.LoadOwner(ctx); err != nil { - ctx.ServerError("LoadOwner", err) - return nil - } - - ctx.Data["repo_name"] = forkRepo.Name - ctx.Data["description"] = forkRepo.Description - ctx.Data["IsPrivate"] = forkRepo.IsPrivate || forkRepo.Owner.Visibility == structs.VisibleTypePrivate - canForkToUser := forkRepo.OwnerID != ctx.Doer.ID && !repo_model.HasForkedRepo(ctx, ctx.Doer.ID, forkRepo.ID) - - ctx.Data["ForkRepo"] = forkRepo - - ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) - if err != nil { - ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) - return nil - } - var orgs []*organization.Organization - for _, org := range ownedOrgs { - if forkRepo.OwnerID != org.ID && !repo_model.HasForkedRepo(ctx, org.ID, forkRepo.ID) { - orgs = append(orgs, org) - } - } - - traverseParentRepo := forkRepo - for { - if ctx.Doer.ID == traverseParentRepo.OwnerID { - canForkToUser = false - } else { - for i, org := range orgs { - if org.ID == traverseParentRepo.OwnerID { - orgs = append(orgs[:i], orgs[i+1:]...) - break - } - } - } - - if !traverseParentRepo.IsFork { - break - } - traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) - if err != nil { - ctx.ServerError("GetRepositoryByID", err) - return nil - } - } - - ctx.Data["CanForkToUser"] = canForkToUser - ctx.Data["Orgs"] = orgs - - if canForkToUser { - ctx.Data["ContextUser"] = ctx.Doer - } else if len(orgs) > 0 { - ctx.Data["ContextUser"] = orgs[0] - } else { - ctx.Data["CanForkRepo"] = false - ctx.Flash.Error(ctx.Tr("repo.fork_no_valid_owners"), true) - return nil - } - - return forkRepo -} - -// Fork render repository fork page -func Fork(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("new_fork") - - if ctx.Doer.CanForkRepo() { - ctx.Data["CanForkRepo"] = true - } else { - maxCreationLimit := ctx.Doer.MaxCreationLimit() - msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.Flash.Error(msg, true) - } - - getForkRepository(ctx) - if ctx.Written() { - return - } - - ctx.HTML(http.StatusOK, tplFork) -} - -// ForkPost response for forking a repository -func ForkPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateRepoForm) - ctx.Data["Title"] = ctx.Tr("new_fork") - ctx.Data["CanForkRepo"] = true - - ctxUser := checkContextUser(ctx, form.UID) - if ctx.Written() { - return - } - - forkRepo := getForkRepository(ctx) - if ctx.Written() { - return - } - - ctx.Data["ContextUser"] = ctxUser - - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplFork) - return - } - - var err error - traverseParentRepo := forkRepo - for { - if ctxUser.ID == traverseParentRepo.OwnerID { - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) - return - } - repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) - if repo != nil { - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) - return - } - if !traverseParentRepo.IsFork { - break - } - traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) - if err != nil { - ctx.ServerError("GetRepositoryByID", err) - return - } - } - - // Check if user is allowed to create repo's on the organization. - if ctxUser.IsOrganization() { - isAllowedToFork, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx.Doer.ID) - if err != nil { - ctx.ServerError("CanCreateOrgRepo", err) - return - } else if !isAllowedToFork { - ctx.Error(http.StatusForbidden) - return - } - } - - repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ - BaseRepo: forkRepo, - Name: form.RepoName, - Description: form.Description, - }) - if err != nil { - ctx.Data["Err_RepoName"] = true - switch { - case repo_model.IsErrReachLimitOfRepo(err): - maxCreationLimit := ctxUser.MaxCreationLimit() - msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.RenderWithErr(msg, tplFork, &form) - case repo_model.IsErrRepoAlreadyExist(err): - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) - case repo_model.IsErrRepoFilesAlreadyExist(err): - switch { - case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) - case setting.Repository.AllowAdoptionOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) - case setting.Repository.AllowDeleteOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) - default: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) - } - case db.IsErrNameReserved(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) - case db.IsErrNamePatternNotAllowed(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) - default: - ctx.ServerError("ForkPost", err) - } - return - } - - log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) -} - func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -317,7 +125,7 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { ctx.ServerError("LoadRepo", err) return nil, false } - ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title) + ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) ctx.Data["Issue"] = issue if !issue.IsPull { @@ -355,8 +163,8 @@ func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) { ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch } ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["HeadBranchLink"] = pull.GetHeadBranchLink() - ctx.Data["BaseBranchLink"] = pull.GetBaseBranchLink() + ctx.Data["HeadBranchLink"] = pull.GetHeadBranchLink(ctx) + ctx.Data["BaseBranchLink"] = pull.GetBaseBranchLink(ctx) } // GetPullDiffStats get Pull Requests diff stats @@ -470,7 +278,7 @@ func PrepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) if len(compareInfo.Commits) != 0 { sha := compareInfo.Commits[0].ID.String() - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptions{ListAll: true}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -514,7 +322,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C if pull.BaseRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { baseGitRepo = ctx.Repo.GitRepo } else { - baseGitRepo, err := git.OpenRepository(ctx, pull.BaseRepo.RepoPath()) + baseGitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo) if err != nil { ctx.ServerError("OpenRepository", err) return nil @@ -532,7 +340,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -566,7 +374,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C var headBranchSha string // HeadRepo may be missing if pull.HeadRepo != nil { - headGitRepo, err := git.OpenRepository(ctx, pull.HeadRepo.RepoPath()) + headGitRepo, err := gitrepo.OpenRepository(ctx, pull.HeadRepo) if err != nil { ctx.ServerError("OpenRepository", err) return nil @@ -624,7 +432,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -635,9 +443,35 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C } if pb != nil && pb.EnableStatusCheck { + + var missingRequiredChecks []string + for _, requiredContext := range pb.StatusCheckContexts { + contextFound := false + matchesRequiredContext := createRequiredContextMatcher(requiredContext) + for _, presentStatus := range commitStatuses { + if matchesRequiredContext(presentStatus.Context) { + contextFound = true + break + } + } + + if !contextFound { + missingRequiredChecks = append(missingRequiredChecks, requiredContext) + } + } + ctx.Data["MissingRequiredChecks"] = missingRequiredChecks + ctx.Data["is_context_required"] = func(context string) bool { for _, c := range pb.StatusCheckContexts { - if gp, err := glob.Compile(c); err == nil && gp.Match(context) { + if c == context { + return true + } + if gp, err := glob.Compile(c); err != nil { + // All newly created status_check_contexts are checked to ensure they are valid glob expressions before being stored in the database. + // But some old status_check_context created before glob was introduced may be invalid glob expressions. + // So log the error here for debugging. + log.Error("compile glob %q: %v", c, err) + } else if gp.Match(context) { return true } } @@ -680,7 +514,7 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C ctx.Data["IsNothingToCompare"] = true } - if pull.IsWorkInProgress() { + if pull.IsWorkInProgress(ctx) { ctx.Data["IsPullWorkInProgress"] = true ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx) } @@ -695,10 +529,22 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C return compareInfo } +func createRequiredContextMatcher(requiredContext string) func(string) bool { + if gp, err := glob.Compile(requiredContext); err == nil { + return func(contextToCheck string) bool { + return gp.Match(contextToCheck) + } + } + + return func(contextToCheck string) bool { + return requiredContext == contextToCheck + } +} + type pullCommitList struct { Commits []pull_service.CommitInfo `json:"commits"` LastReviewCommitSha string `json:"last_review_commit_sha"` - Locale map[string]string `json:"locale"` + Locale map[string]any `json:"locale"` } // GetPullCommits get all commits for given pull request @@ -716,7 +562,7 @@ func GetPullCommits(ctx *context.Context) { } // Get the needed locale - resp.Locale = map[string]string{ + resp.Locale = map[string]any{ "lang": ctx.Locale.Language(), "show_all_commits": ctx.Tr("repo.pulls.show_all_commits"), "stats_num_commits": ctx.TrN(len(commits), "repo.activity.git_stats_commit_1", "repo.activity.git_stats_commit_n", len(commits)), @@ -892,7 +738,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi // as the viewed information is designed to be loaded only on latest PR // diff and if you're signed in. if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { - diff, err = gitdiff.GetDiff(gitRepo, diffOptions, files...) + diff, err = gitdiff.GetDiff(ctx, gitRepo, diffOptions, files...) methodWithError = "GetDiff" } else { diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...) @@ -913,6 +759,19 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi return } + for _, file := range diff.Files { + for _, section := range file.Sections { + for _, line := range section.Lines { + for _, comment := range line.Comments { + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + } + } + } + } + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) if err != nil { ctx.ServerError("LoadProtectedBranch", err) @@ -943,7 +802,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } if ctx.IsSigned && ctx.Doer != nil { - if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(issue, ctx.Doer); err != nil { + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { ctx.ServerError("CanMarkConversation", err) return } @@ -970,7 +829,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } numPendingCodeComments := int64(0) if currentReview != nil { - numPendingCodeComments, err = issues_model.CountComments(&issues_model.FindCommentsOptions{ + numPendingCodeComments, err = issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{ Type: issues_model.CommentTypeCode, ReviewID: currentReview.ID, IssueID: issue.ID, @@ -995,6 +854,36 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } upload.AddUploadContext(ctx, "comment") + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + if !willShowSpecifiedCommit && !willShowSpecifiedCommitRange && pull.Flow == issues_model.PullRequestFlowGithub { + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } + + if pull.HeadRepo != nil { + ctx.Data["SourcePath"] = pull.HeadRepo.Link() + "/src/branch/" + util.PathEscapeSegments(pull.HeadBranch) + } + + if !pull.HasMerged && ctx.Doer != nil { + perm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if perm.CanWrite(unit.TypeCode) || issues_model.CanMaintainerWriteToBranch(ctx, perm, pull.HeadBranch, ctx.Doer) { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") + ctx.Data["HeadRepoLink"] = pull.HeadRepo.Link() + ctx.Data["HeadBranchName"] = pull.HeadBranch + ctx.Data["BackToLink"] = setting.AppSubURL + ctx.Req.URL.RequestURI() + } + } + } + ctx.HTML(http.StatusOK, tplPullFiles) } @@ -1059,7 +948,7 @@ func UpdatePullRequest(ctx *context.Context) { if err = pull_service.Update(ctx, issue.PullRequest, ctx.Doer, message, rebase); err != nil { if models.IsErrMergeConflicts(err) { conflictError := err.(models.ErrMergeConflicts) - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.merge_conflict"), "Summary": ctx.Tr("repo.pulls.merge_conflict_summary"), "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), @@ -1073,7 +962,7 @@ func UpdatePullRequest(ctx *context.Context) { return } else if models.IsErrRebaseConflicts(err) { conflictError := err.(models.ErrRebaseConflicts) - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), @@ -1125,49 +1014,47 @@ func MergePullRequest(ctx *context.Context) { switch { case errors.Is(err, pull_service.ErrIsClosed): if issue.IsPull { - ctx.Flash.Error(ctx.Tr("repo.pulls.is_closed")) + ctx.JSONError(ctx.Tr("repo.pulls.is_closed")) } else { - ctx.Flash.Error(ctx.Tr("repo.issues.closed_title")) + ctx.JSONError(ctx.Tr("repo.issues.closed_title")) } case errors.Is(err, pull_service.ErrUserNotAllowedToMerge): - ctx.Flash.Error(ctx.Tr("repo.pulls.update_not_allowed")) + ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed")) case errors.Is(err, pull_service.ErrHasMerged): - ctx.Flash.Error(ctx.Tr("repo.pulls.has_merged")) + ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) case errors.Is(err, pull_service.ErrIsWorkInProgress): - ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_wip")) + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip")) case errors.Is(err, pull_service.ErrNotMergableState): - ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready")) + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) case models.IsErrDisallowedToMerge(err): - ctx.Flash.Error(ctx.Tr("repo.pulls.no_merge_not_ready")) + ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) case asymkey_service.IsErrWontSign(err): - ctx.Flash.Error(err.Error()) // has no translation ... + ctx.JSONError(err.Error()) // has no translation ... case errors.Is(err, pull_service.ErrDependenciesLeft): - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) default: ctx.ServerError("WebCheck", err) - return } - ctx.Redirect(issue.Link()) return } // handle manually-merged mark if manuallyMerged { - if err := pull_service.MergedManually(pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { switch { - case models.IsErrInvalidMergeStyle(err): - ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) + ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) case strings.Contains(err.Error(), "Wrong commit ID"): - ctx.Flash.Error(ctx.Tr("repo.pulls.wrong_commit_id")) + ctx.JSONError(ctx.Tr("repo.pulls.wrong_commit_id")) default: ctx.ServerError("MergedManually", err) - return } + + return } - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) return } @@ -1197,18 +1084,17 @@ func MergePullRequest(ctx *context.Context) { } else if scheduled { // nothing more to do ... ctx.Flash.Success(ctx.Tr("repo.pulls.auto_merge_newly_scheduled")) - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pr.Index)) + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pr.Index)) return } } if err := pull_service.Merge(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { if models.IsErrInvalidMergeStyle(err) { - ctx.Flash.Error(ctx.Tr("repo.pulls.invalid_merge_option")) - ctx.Redirect(issue.Link()) + ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option")) } else if models.IsErrMergeConflicts(err) { conflictError := err.(models.ErrMergeConflicts) - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.merge_conflict"), "Summary": ctx.Tr("repo.editor.merge_conflict_summary"), "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), @@ -1218,10 +1104,10 @@ func MergePullRequest(ctx *context.Context) { return } ctx.Flash.Error(flashError) - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else if models.IsErrRebaseConflicts(err) { conflictError := err.(models.ErrRebaseConflicts) - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), @@ -1231,19 +1117,19 @@ func MergePullRequest(ctx *context.Context) { return } ctx.Flash.Error(flashError) - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else if models.IsErrMergeUnrelatedHistories(err) { log.Debug("MergeUnrelatedHistories error: %v", err) ctx.Flash.Error(ctx.Tr("repo.pulls.unrelated_histories")) - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else if git.IsErrPushOutOfDate(err) { log.Debug("MergePushOutOfDate error: %v", err) ctx.Flash.Error(ctx.Tr("repo.pulls.merge_out_of_date")) - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else if models.IsErrSHADoesNotMatch(err) { log.Debug("MergeHeadOutOfDate error: %v", err) ctx.Flash.Error(ctx.Tr("repo.pulls.head_out_of_date")) - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else if git.IsErrPushRejected(err) { log.Debug("MergePushRejected error: %v", err) pushrejErr := err.(*git.ErrPushRejected) @@ -1251,7 +1137,7 @@ func MergePullRequest(ctx *context.Context) { if len(message) == 0 { ctx.Flash.Error(ctx.Tr("repo.pulls.push_rejected_no_message")) } else { - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.push_rejected"), "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), @@ -1262,7 +1148,7 @@ func MergePullRequest(ctx *context.Context) { } ctx.Flash.Error(flashError) } - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } else { ctx.ServerError("Merge", err) } @@ -1270,8 +1156,8 @@ func MergePullRequest(ctx *context.Context) { } log.Trace("Pull request merged: %d", pr.ID) - if err := stopTimerIfAvailable(ctx.Doer, issue); err != nil { - ctx.ServerError("CreateOrStopIssueStopwatch", err) + if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { + ctx.ServerError("stopTimerIfAvailable", err) return } @@ -1285,7 +1171,7 @@ func MergePullRequest(ctx *context.Context) { return } if exist { - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) return } @@ -1293,9 +1179,9 @@ func MergePullRequest(ctx *context.Context) { if ctx.Repo != nil && ctx.Repo.Repository != nil && pr.HeadRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { headRepo = ctx.Repo.GitRepo } else { - headRepo, err = git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { - ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.RepoPath()), err) + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) return } defer headRepo.Close() @@ -1303,7 +1189,7 @@ func MergePullRequest(ctx *context.Context) { deleteBranch(ctx, pr, headRepo) } - ctx.Redirect(issue.Link()) + ctx.JSONRedirect(issue.Link()) } // CancelAutoMergePullRequest cancels a scheduled pr @@ -1326,9 +1212,9 @@ func CancelAutoMergePullRequest(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) } -func stopTimerIfAvailable(user *user_model.User, issue *issues_model.Issue) error { - if issues_model.StopwatchExists(user.ID, issue.ID) { - if err := issues_model.CreateOrStopIssueStopwatch(user, issue); err != nil { +func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error { + if issues_model.StopwatchExists(ctx, user.ID, issue.ID) { + if err := issues_model.CreateOrStopIssueStopwatch(ctx, user, issue); err != nil { return err } } @@ -1363,7 +1249,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - labelIDs, assigneeIDs, milestoneID, _ := ValidateRepoMetas(ctx, *form, true) + labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, true) if ctx.Written() { return } @@ -1416,7 +1302,6 @@ func CompareAndPullRequestPost(ctx *context.Context) { if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) - return } else if git.IsErrPushRejected(err) { pushrejErr := err.(*git.ErrPushRejected) message := pushrejErr.Message @@ -1424,7 +1309,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { ctx.JSONError(ctx.Tr("repo.pulls.push_rejected_no_message")) return } - flashError, err := ctx.RenderToString(tplAlertDetails, map[string]any{ + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.push_rejected"), "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), @@ -1433,14 +1318,32 @@ func CompareAndPullRequestPost(ctx *context.Context) { ctx.ServerError("CompareAndPullRequest.HTMLString", err) return } - ctx.Flash.Error(flashError) - ctx.JSONRedirect(pullIssue.Link()) // FIXME: it's unfriendly, and will make the content lost - return + ctx.JSONError(flashError) + } else if errors.Is(err, user_model.ErrBlockedUser) { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.new.blocked_user"), + }) + if err != nil { + ctx.ServerError("CompareAndPullRequest.HTMLString", err) + return + } + ctx.JSONError(flashError) } - ctx.ServerError("NewPullRequest", err) return } + if projectID > 0 { + if !ctx.Repo.CanWrite(unit.TypeProjects) { + ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects") + return + } + if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) ctx.JSONRedirect(pullIssue.Link()) } @@ -1505,9 +1408,9 @@ func CleanUpPullRequest(ctx *context.Context) { gitBaseRepo = ctx.Repo.GitRepo } else { // If not just open it - gitBaseRepo, err = git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + gitBaseRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { - ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.RepoPath()), err) + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.FullName()), err) return } defer gitBaseRepo.Close() @@ -1520,9 +1423,9 @@ func CleanUpPullRequest(ctx *context.Context) { gitRepo = ctx.Repo.GitRepo } else if pr.BaseRepoID != pr.HeadRepoID { // Otherwise just load it up - gitRepo, err = git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + gitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { - ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.RepoPath()), err) + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) return } defer gitRepo.Close() @@ -1555,6 +1458,12 @@ func CleanUpPullRequest(ctx *context.Context) { func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) { fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch + + if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { + ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName)) + return + } + if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, gitRepo, pr.HeadBranch); err != nil { switch { case git.IsErrBranchNotExist(err): diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 3e433dcf4d7..c8d149a4829 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -10,19 +10,24 @@ import ( issues_model "code.gitea.io/gitea/models/issues" pull_model "code.gitea.io/gitea/models/pull" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" pull_service "code.gitea.io/gitea/services/pull" + user_service "code.gitea.io/gitea/services/user" ) const ( - tplConversation base.TplName = "repo/diff/conversation" - tplNewComment base.TplName = "repo/diff/new_comment" + tplDiffConversation base.TplName = "repo/diff/conversation" + tplConversationOutdated base.TplName = "repo/diff/conversation_outdated" + tplTimelineConversation base.TplName = "repo/issue/view_content/conversation" + tplNewComment base.TplName = "repo/diff/new_comment" ) // RenderNewCodeCommentForm will render the form for creating a new review comment @@ -48,6 +53,8 @@ func RenderNewCodeCommentForm(ctx *context.Context) { return } ctx.Data["AfterCommitID"] = pullHeadCommitID + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") ctx.HTML(http.StatusOK, tplNewComment) } @@ -73,6 +80,11 @@ func CreateCodeComment(ctx *context.Context) { signedLine *= -1 } + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + comment, err := pull_service.CreateCodeComment(ctx, ctx.Doer, ctx.Repo.GitRepo, @@ -83,6 +95,7 @@ func CreateCodeComment(ctx *context.Context) { !form.SingleReview, form.Reply, form.LatestCommitID, + attachments, ) if err != nil { ctx.ServerError("CreateCodeComment", err) @@ -97,11 +110,7 @@ func CreateCodeComment(ctx *context.Context) { log.Trace("Comment created: %-v #%d[%d] Comment[%d]", ctx.Repo.Repository, issue.Index, issue.ID, comment.ID) - if form.Origin == "diff" { - renderConversation(ctx, comment) - return - } - ctx.Redirect(comment.Link()) + renderConversation(ctx, comment, form.Origin) } // UpdateResolveConversation add or remove an Conversation resolved mark @@ -127,7 +136,7 @@ func UpdateResolveConversation(ctx *context.Context) { } var permResult bool - if permResult, err = issues_model.CanMarkConversation(comment.Issue, ctx.Doer); err != nil { + if permResult, err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { ctx.ServerError("CanMarkConversation", err) return } @@ -142,7 +151,7 @@ func UpdateResolveConversation(ctx *context.Context) { } if action == "Resolve" || action == "UnResolve" { - err = issues_model.MarkConversation(comment, ctx.Doer, action == "Resolve") + err = issues_model.MarkConversation(ctx, comment, ctx.Doer, action == "Resolve") if err != nil { ctx.ServerError("MarkConversation", err) return @@ -152,22 +161,37 @@ func UpdateResolveConversation(ctx *context.Context) { return } - if origin == "diff" { - renderConversation(ctx, comment) - return - } - ctx.JSONOK() + renderConversation(ctx, comment, origin) } -func renderConversation(ctx *context.Context, comment *issues_model.Comment) { - comments, err := issues_model.FetchCodeCommentsByLine(ctx, comment.Issue, ctx.Doer, comment.TreePath, comment.Line, ctx.Data["ShowOutdatedComments"].(bool)) +func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) { + ctx.Data["PageIsPullFiles"] = origin == "diff" + + showOutdatedComments := origin == "timeline" || ctx.Data["ShowOutdatedComments"].(bool) + comments, err := issues_model.FetchCodeCommentsByLine(ctx, comment.Issue, ctx.Doer, comment.TreePath, comment.Line, showOutdatedComments) if err != nil { ctx.ServerError("FetchCodeCommentsByLine", err) return } - ctx.Data["PageIsPullFiles"] = true + if len(comments) == 0 { + // if the comments are empty (deleted, outdated, etc), it's better to tell the users that it is outdated + ctx.HTML(http.StatusOK, tplConversationOutdated) + return + } + + if err := comments.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + ctx.Data["comments"] = comments - ctx.Data["CanMarkConversation"] = true + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return + } ctx.Data["Issue"] = comment.Issue if err = comment.Issue.LoadPullRequest(ctx); err != nil { ctx.ServerError("comment.Issue.LoadPullRequest", err) @@ -179,7 +203,17 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment) { return } ctx.Data["AfterCommitID"] = pullHeadCommitID - ctx.HTML(http.StatusOK, tplConversation) + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + if origin == "diff" { + ctx.HTML(http.StatusOK, tplDiffConversation) + } else if origin == "timeline" { + ctx.HTML(http.StatusOK, tplTimelineConversation) + } else { + ctx.Error(http.StatusBadRequest, "Unknown origin: "+origin) + } } // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist @@ -209,9 +243,9 @@ func SubmitReview(ctx *context.Context) { if issue.IsPoster(ctx.Doer.ID) { var translated string if reviewType == issues_model.ReviewTypeApprove { - translated = ctx.Tr("repo.issues.review.self.approval") + translated = ctx.Locale.TrString("repo.issues.review.self.approval") } else { - translated = ctx.Tr("repo.issues.review.self.rejection") + translated = ctx.Locale.TrString("repo.issues.review.self.rejection") } ctx.Flash.Error(translated) @@ -243,6 +277,10 @@ func DismissReview(ctx *context.Context) { form := web.GetForm(ctx).(*forms.DismissReviewForm) comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true) if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Status(http.StatusForbidden) + return + } ctx.ServerError("pull_service.DismissReview", err) return } diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go new file mode 100644 index 00000000000..8344ff40911 --- /dev/null +++ b/routers/web/repo/pull_review_test.go @@ -0,0 +1,93 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" +) + +func TestRenderConversation(t *testing.T) { + unittest.PrepareTestEnv(t) + + pr, _ := issues_model.GetPullRequestByID(db.DefaultContext, 2) + _ = pr.LoadIssue(db.DefaultContext) + _ = pr.Issue.LoadPoster(db.DefaultContext) + _ = pr.Issue.LoadRepo(db.DefaultContext) + + run := func(name string, cb func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder)) { + t.Run(name, func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + contexttest.LoadUser(t, ctx, pr.Issue.PosterID) + contexttest.LoadRepo(t, ctx, pr.BaseRepoID) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + cb(t, ctx, resp) + }) + } + + var preparedComment *issues_model.Comment + run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID, nil) + if !assert.NoError(t, err) { + return + } + comment.Invalidated = true + err = issues_model.UpdateCommentInvalidate(ctx, comment) + if !assert.NoError(t, err) { + return + } + preparedComment = comment + }) + if !assert.NotNil(t, preparedComment) { + return + } + run("diff with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { + ctx.Data["ShowOutdatedComments"] = true + renderConversation(ctx, preparedComment, "diff") + assert.Contains(t, resp.Body.String(), `
setting.API.MaxResponseItems { - listOptions.PageSize = setting.API.MaxResponseItems - } - - // TODO(20073) tags are used for compare feature which needs all tags - // filtering is done on the client-side atm - tagListStart, tagListEnd := 0, 0 - if isTagList { - tagListStart, tagListEnd = listOptions.GetStartEnd() - } - - tags, err := ctx.Repo.GitRepo.GetTags(tagListStart, tagListEnd) +func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) ([]*ReleaseInfo, error) { + releases, err := db.Find[repo_model.Release](ctx, opts) if err != nil { - ctx.ServerError("GetTags", err) - return - } - ctx.Data["Tags"] = tags - - writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) - ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived - - opts := repo_model.FindReleasesOptions{ - ListOptions: listOptions, - } - if isTagList { - // for the tags list page, show all releases with real tags (having real commit-id), - // the drafts should also be included because a real tag might be used as a draft. - opts.IncludeDrafts = true - opts.IncludeTags = true - opts.HasSha1 = util.OptionalBoolTrue - } else { - // only show draft releases for users who can write, read-only users shouldn't see draft releases. - opts.IncludeDrafts = writeAccess - } - - releases, err := repo_model.GetReleasesByRepoID(ctx, ctx.Repo.Repository.ID, opts) - if err != nil { - ctx.ServerError("GetReleasesByRepoID", err) - return - } - - count, err := repo_model.GetReleaseCountByRepoID(ctx, ctx.Repo.Repository.ID, opts) - if err != nil { - ctx.ServerError("GetReleaseCountByRepoID", err) - return + return nil, err } for _, release := range releases { @@ -148,8 +86,7 @@ func releasesOrTags(ctx *context.Context, isTagList bool) { } if err = repo_model.GetReleaseAttachments(ctx, releases...); err != nil { - ctx.ServerError("GetReleaseAttachments", err) - return + return nil, err } // Temporary cache commits count of used branches to speed up. @@ -160,6 +97,9 @@ func releasesOrTags(ctx *context.Context, isTagList bool) { } var ok bool + canReadActions := ctx.Repo.CanRead(unit.TypeActions) + + releaseInfos := make([]*ReleaseInfo, 0, len(releases)) for _, r := range releases { if r.Publisher, ok = cacheUsers[r.PublisherID]; !ok { r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) @@ -167,46 +107,147 @@ func releasesOrTags(ctx *context.Context, isTagList bool) { if user_model.IsErrUserNotExist(err) { r.Publisher = user_model.NewGhostUser() } else { - ctx.ServerError("GetUserByID", err) - return + return nil, err } } cacheUsers[r.PublisherID] = r.Publisher } - r.Note, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + r.RenderedNote, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Ctx: ctx, }, r.Note) if err != nil { - ctx.ServerError("RenderString", err) - return + return nil, err } - if r.IsDraft { - continue + if !r.IsDraft { + if err := calReleaseNumCommitsBehind(ctx.Repo, r, countCache); err != nil { + return nil, err + } } - if err := calReleaseNumCommitsBehind(ctx.Repo, r, countCache); err != nil { - ctx.ServerError("calReleaseNumCommitsBehind", err) - return + info := &ReleaseInfo{ + Release: r, + } + + if canReadActions { + statuses, _, err := git_model.GetLatestCommitStatus(ctx, r.Repo.ID, r.Sha1, db.ListOptionsAll) + if err != nil { + return nil, err + } + + info.CommitStatus = git_model.CalcCommitStatus(statuses) + info.CommitStatuses = statuses + } + + releaseInfos = append(releaseInfos, info) + } + + return releaseInfos, nil +} + +// Releases render releases list page +func Releases(ctx *context.Context) { + ctx.Data["PageIsReleaseList"] = true + ctx.Data["Title"] = ctx.Tr("repo.release.releases") + ctx.Data["IsViewBranch"] = false + ctx.Data["IsViewTag"] = true + // Disable the showCreateNewBranch form in the dropdown on this page. + ctx.Data["CanCreateBranch"] = false + ctx.Data["HideBranchesInDropdown"] = true + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: ctx.FormInt("limit"), + } + if listOptions.PageSize == 0 { + listOptions.PageSize = setting.Repository.Release.DefaultPagingNum + } + if listOptions.PageSize > setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived + + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("getReleaseInfos", err) + return + } + for _, rel := range releases { + if rel.Release.IsTag && rel.Release.Title == "" { + rel.Release.Title = rel.Release.TagName } } ctx.Data["Releases"] = releases - pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + numReleases := ctx.Data["NumReleases"].(int64) + pager := context.NewPagination(int(numReleases), listOptions.PageSize, listOptions.Page, 5) pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager - if isTagList { - ctx.Data["PageIsViewCode"] = !ctx.Repo.Repository.UnitEnabled(ctx, unit.TypeReleases) - ctx.HTML(http.StatusOK, tplTagsList) - } else { - ctx.HTML(http.StatusOK, tplReleasesList) + ctx.HTML(http.StatusOK, tplReleasesList) +} + +// TagsList render tags list page +func TagsList(ctx *context.Context) { + ctx.Data["PageIsTagList"] = true + ctx.Data["Title"] = ctx.Tr("repo.release.tags") + ctx.Data["IsViewBranch"] = false + ctx.Data["IsViewTag"] = true + // Disable the showCreateNewBranch form in the dropdown on this page. + ctx.Data["CanCreateBranch"] = false + ctx.Data["HideBranchesInDropdown"] = true + ctx.Data["CanCreateRelease"] = ctx.Repo.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: ctx.FormInt("limit"), } + if listOptions.PageSize == 0 { + listOptions.PageSize = setting.Repository.Release.DefaultPagingNum + } + if listOptions.PageSize > setting.API.MaxResponseItems { + listOptions.PageSize = setting.API.MaxResponseItems + } + + opts := repo_model.FindReleasesOptions{ + ListOptions: listOptions, + // for the tags list page, show all releases with real tags (having real commit-id), + // the drafts should also be included because a real tag might be used as a draft. + IncludeDrafts: true, + IncludeTags: true, + HasSha1: optional.Some(true), + RepoID: ctx.Repo.Repository.ID, + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.ServerError("GetReleasesByRepoID", err) + return + } + + ctx.Data["Releases"] = releases + + numTags := ctx.Data["NumTags"].(int64) + pager := context.NewPagination(int(numTags), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["PageIsViewCode"] = !ctx.Repo.Repository.UnitEnabled(ctx, unit.TypeReleases) + ctx.HTML(http.StatusOK, tplTagsList) } // ReleasesFeedRSS get feeds for releases in RSS format @@ -241,15 +282,28 @@ func SingleRelease(ctx *context.Context) { writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived - release, err := repo_model.GetRelease(ctx.Repo.Repository.ID, ctx.Params("*")) + releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{Page: 1, PageSize: 1}, + RepoID: ctx.Repo.Repository.ID, + TagNames: []string{ctx.Params("*")}, + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: writeAccess, + IncludeTags: true, + }) if err != nil { - if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound("GetRelease", err) - return - } - ctx.ServerError("GetReleasesByRepoID", err) + ctx.ServerError("getReleaseInfos", err) return } + if len(releases) != 1 { + ctx.NotFound("SingleRelease", err) + return + } + + release := releases[0].Release + if release.IsTag && release.Title == "" { + release.Title = release.TagName + } + ctx.Data["PageIsSingleTag"] = release.IsTag if release.IsTag { ctx.Data["Title"] = release.TagName @@ -257,47 +311,13 @@ func SingleRelease(ctx *context.Context) { ctx.Data["Title"] = release.Title } - release.Repo = ctx.Repo.Repository - - err = repo_model.GetReleaseAttachments(ctx, release) - if err != nil { - ctx.ServerError("GetReleaseAttachments", err) - return - } - - release.Publisher, err = user_model.GetUserByID(ctx, release.PublisherID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - release.Publisher = user_model.NewGhostUser() - } else { - ctx.ServerError("GetUserByID", err) - return - } - } - if !release.IsDraft { - if err := calReleaseNumCommitsBehind(ctx.Repo, release, make(map[string]int64)); err != nil { - ctx.ServerError("calReleaseNumCommitsBehind", err) - return - } - } - release.Note, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeMetas(), - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, - }, release.Note) - if err != nil { - ctx.ServerError("RenderString", err) - return - } - - ctx.Data["Releases"] = []*repo_model.Release{release} + ctx.Data["Releases"] = releases ctx.HTML(http.StatusOK, tplReleasesList) } // LatestRelease redirects to the latest release func LatestRelease(ctx *context.Context) { - release, err := repo_model.GetLatestReleaseByRepoID(ctx.Repo.Repository.ID) + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound("LatestRelease", err) @@ -321,7 +341,7 @@ func NewRelease(ctx *context.Context) { ctx.Data["PageIsReleaseList"] = true ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch if tagName := ctx.FormString("tag"); len(tagName) > 0 { - rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil && !repo_model.IsErrReleaseNotExist(err) { ctx.ServerError("GetRelease", err) return @@ -403,7 +423,7 @@ func NewReleasePost(ctx *context.Context) { attachmentUUIDs = form.Files } - rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, form.TagName) + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) if err != nil { if !repo_model.IsErrReleaseNotExist(err) { ctx.ServerError("GetRelease", err) @@ -488,7 +508,7 @@ func NewReleasePost(ctx *context.Context) { rel.PublisherID = ctx.Doer.ID rel.IsTag = false - if err = releaseservice.UpdateRelease(ctx.Doer, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil); err != nil { + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil); err != nil { ctx.Data["Err_TagName"] = true ctx.ServerError("UpdateRelease", err) return @@ -508,7 +528,7 @@ func EditRelease(ctx *context.Context) { upload.AddUploadContext(ctx, "release") tagName := ctx.Params("*") - rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound("GetRelease", err) @@ -551,7 +571,7 @@ func EditReleasePost(ctx *context.Context) { ctx.Data["PageIsEditRelease"] = true tagName := ctx.Params("*") - rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil { if repo_model.IsErrReleaseNotExist(err) { ctx.NotFound("GetRelease", err) @@ -594,7 +614,7 @@ func EditReleasePost(ctx *context.Context) { rel.Note = form.Content rel.IsDraft = len(form.Draft) > 0 rel.IsPrerelease = form.Prerelease - if err = releaseservice.UpdateRelease(ctx.Doer, ctx.Repo.GitRepo, + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments); err != nil { ctx.ServerError("UpdateRelease", err) return @@ -613,7 +633,27 @@ func DeleteTag(ctx *context.Context) { } func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { - if err := releaseservice.DeleteReleaseByID(ctx, ctx.FormInt64("id"), ctx.Doer, isDelTag); err != nil { + redirect := func() { + if isDelTag { + ctx.JSONRedirect(ctx.Repo.RepoLink + "/tags") + return + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases") + } + + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound("GetReleaseForRepoByID", err) + } else { + ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) + redirect() + } + return + } + + if err := releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { if models.IsErrProtectedTagName(err) { ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) } else { @@ -627,10 +667,5 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { } } - if isDelTag { - ctx.JSONRedirect(ctx.Repo.RepoLink + "/tags") - return - } - - ctx.JSONRedirect(ctx.Repo.RepoLink + "/releases") + redirect() } diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go index 54118fb6b3e..7ebea4c3fbe 100644 --- a/routers/web/repo/release_test.go +++ b/routers/web/repo/release_test.go @@ -6,10 +6,12 @@ package repo import ( "testing" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" @@ -65,7 +67,7 @@ func TestNewReleasePost(t *testing.T) { } } -func TestNewReleasesList(t *testing.T) { +func TestCalReleaseNumCommitsBehind(t *testing.T) { unittest.PrepareTestEnv(t) ctx, _ := contexttest.MockContext(t, "user2/repo-release/releases") contexttest.LoadUser(t, ctx, 2) @@ -73,8 +75,18 @@ func TestNewReleasesList(t *testing.T) { contexttest.LoadGitRepo(t, ctx) t.Cleanup(func() { ctx.Repo.GitRepo.Close() }) - Releases(ctx) - releases := ctx.Data["Releases"].([]*repo_model.Release) + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: ctx.Repo.Repository.ID, + }) + assert.NoError(t, err) + + countCache := make(map[string]int64) + for _, release := range releases { + err := calReleaseNumCommitsBehind(ctx.Repo, release, countCache) + assert.NoError(t, err) + } + type computedFields struct { NumCommitsBehind int64 TargetBehind string diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index f07b4e8c113..e64db03e201 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -10,11 +10,12 @@ import ( "path" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) // RenderFile renders a file by repos path @@ -43,36 +44,33 @@ func RenderFile(ctx *context.Context) { st := typesniffer.DetectContentType(buf) isTextFile := st.IsText() - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts") if markupType := markup.Type(blob.Name()); markupType == "" { if isTextFile { - _, err = io.Copy(ctx.Resp, rd) - if err != nil { - ctx.ServerError("Copy", err) - } - return + _, _ = io.Copy(ctx.Resp, rd) + } else { + http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError) } - ctx.Error(http.StatusInternalServerError, "Unsupported file type render") return } - treeLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() - if ctx.Repo.TreePath != "" { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts") err = markup.Render(&markup.RenderContext{ - Ctx: ctx, - RelativePath: ctx.Repo.TreePath, - URLPrefix: path.Dir(treeLink), - Metas: ctx.Repo.Repository.ComposeDocumentMetas(), + Ctx: ctx, + RelativePath: ctx.Repo.TreePath, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), GitRepo: ctx.Repo.GitRepo, InStandalonePage: true, }, rd, ctx.Resp) if err != nil { - ctx.ServerError("Render", err) + log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err) + http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError) return } } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 799c2268de8..4e448933c79 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -21,19 +21,21 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/cache" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" ) const ( @@ -119,7 +121,7 @@ func checkContextUser(ctx *context.Context, uid int64) *user_model.User { return nil } if !ctx.Doer.IsAdmin { - canCreate, err := organization.OrgFromUser(org).CanCreateOrgRepo(ctx.Doer.ID) + canCreate, err := organization.OrgFromUser(org).CanCreateOrgRepo(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("CanCreateOrgRepo", err) return nil @@ -178,6 +180,8 @@ func Create(ctx *context.Context) { ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat ctx.HTML(http.StatusOK, tplCreate) } @@ -241,7 +245,7 @@ func CreatePost(ctx *context.Context) { var repo *repo_model.Repository var err error if form.RepoTemplate > 0 { - opts := repo_module.GenerateRepoOptions{ + opts := repo_service.GenerateRepoOptions{ Name: form.RepoName, Description: form.Description, Private: form.Private, @@ -277,17 +281,18 @@ func CreatePost(ctx *context.Context) { } } else { repo, err = repo_service.CreateRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ - Name: form.RepoName, - Description: form.Description, - Gitignores: form.Gitignores, - IssueLabels: form.IssueLabels, - License: form.License, - Readme: form.Readme, - IsPrivate: form.Private || setting.Repository.ForcePrivate, - DefaultBranch: form.DefaultBranch, - AutoInit: form.AutoInit, - IsTemplate: form.Template, - TrustModel: repo_model.ToTrustModel(form.TrustModel), + Name: form.RepoName, + Description: form.Description, + Gitignores: form.Gitignores, + IssueLabels: form.IssueLabels, + License: form.License, + Readme: form.Readme, + IsPrivate: form.Private || setting.Repository.ForcePrivate, + DefaultBranch: form.DefaultBranch, + AutoInit: form.AutoInit, + IsTemplate: form.Template, + TrustModel: repo_model.DefaultTrustModel, + ObjectFormatName: form.ObjectFormatName, }) if err == nil { log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) @@ -299,18 +304,23 @@ func CreatePost(ctx *context.Context) { handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form) } +const ( + tplWatchUnwatch base.TplName = "repo/watch_unwatch" + tplStarUnstar base.TplName = "repo/star_unstar" +) + // Action response for actions to a repository func Action(ctx *context.Context) { var err error switch ctx.Params(":action") { case "watch": - err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unwatch": - err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) case "star": - err = repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unstar": - err = repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) case "accept_transfer": err = acceptOrRejectRepoTransfer(ctx, true) case "reject_transfer": @@ -327,11 +337,41 @@ func Action(ctx *context.Context) { } if err != nil { - ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) + } else { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + } + + switch ctx.Params(":action") { + case "watch", "unwatch": + ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + case "star", "unstar": + ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + } + + switch ctx.Params(":action") { + case "watch", "unwatch", "star", "unstar": + // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + } + + switch ctx.Params(":action") { + case "watch", "unwatch": + ctx.HTML(http.StatusOK, tplWatchUnwatch) + return + case "star", "unstar": + ctx.HTML(http.StatusOK, tplStarUnstar) return } - ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) + ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) } func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { @@ -344,7 +384,7 @@ func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { return err } - if !repoTransfer.CanUserAcceptTransfer(ctx.Doer) { + if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { return errors.New("user does not have enough permissions") } @@ -359,7 +399,7 @@ func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { } ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) } else { - if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { return err } ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) @@ -377,7 +417,10 @@ func RedirectDownload(ctx *context.Context) { ) tagNames := []string{vTag} curRepo := ctx.Repo.Repository - releases, err := repo_model.GetReleasesByRepoIDAndNames(ctx, curRepo.ID, tagNames) + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + RepoID: curRepo.ID, + TagNames: tagNames, + }) if err != nil { ctx.ServerError("RedirectDownload", err) return @@ -396,7 +439,7 @@ func RedirectDownload(ctx *context.Context) { } else if len(releases) == 0 && vTag == "latest" { // GitHub supports the alias "latest" for the latest release // We only fetch the latest release if the tag is "latest" and no release with the tag "latest" exists - release, err := repo_model.GetLatestReleaseByRepoID(ctx.Repo.Repository.ID) + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.Error(http.StatusNotFound) return @@ -504,9 +547,13 @@ func InitiateDownload(ctx *context.Context) { // SearchRepo repositories via options func SearchRepo(ctx *context.Context) { + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } opts := &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ - Page: ctx.FormInt("page"), + Page: page, PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, Actor: ctx.Doer, @@ -515,33 +562,33 @@ func SearchRepo(ctx *context.Context) { PriorityOwnerID: ctx.FormInt64("priority_owner_id"), TeamID: ctx.FormInt64("team_id"), TopicOnly: ctx.FormBool("topic"), - Collaborate: util.OptionalBoolNone, + Collaborate: optional.None[bool](), Private: ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")), - Template: util.OptionalBoolNone, + Template: optional.None[bool](), StarredByID: ctx.FormInt64("starredBy"), IncludeDescription: ctx.FormBool("includeDesc"), } if ctx.FormString("template") != "" { - opts.Template = util.OptionalBoolOf(ctx.FormBool("template")) + opts.Template = optional.Some(ctx.FormBool("template")) } if ctx.FormBool("exclusive") { - opts.Collaborate = util.OptionalBoolFalse + opts.Collaborate = optional.Some(false) } mode := ctx.FormString("mode") switch mode { case "source": - opts.Fork = util.OptionalBoolFalse - opts.Mirror = util.OptionalBoolFalse + opts.Fork = optional.Some(false) + opts.Mirror = optional.Some(false) case "fork": - opts.Fork = util.OptionalBoolTrue + opts.Fork = optional.Some(true) case "mirror": - opts.Mirror = util.OptionalBoolTrue + opts.Mirror = optional.Some(true) case "collaborative": - opts.Mirror = util.OptionalBoolFalse - opts.Collaborate = util.OptionalBoolTrue + opts.Mirror = optional.Some(false) + opts.Collaborate = optional.Some(true) case "": default: ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid search mode: \"%s\"", mode)) @@ -549,11 +596,11 @@ func SearchRepo(ctx *context.Context) { } if ctx.FormString("archived") != "" { - opts.Archived = util.OptionalBoolOf(ctx.FormBool("archived")) + opts.Archived = optional.Some(ctx.FormBool("archived")) } if ctx.FormString("is_private") != "" { - opts.IsPrivate = util.OptionalBoolOf(ctx.FormBool("is_private")) + opts.IsPrivate = optional.Some(ctx.FormBool("is_private")) } sortMode := ctx.FormString("sort") @@ -575,47 +622,36 @@ func SearchRepo(ctx *context.Context) { } } - var err error - repos, count, err := repo_model.SearchRepository(ctx, opts) - if err != nil { - ctx.JSON(http.StatusInternalServerError, api.SearchError{ - OK: false, - Error: err.Error(), - }) - return - } - - ctx.SetTotalCountHeader(count) - // To improve performance when only the count is requested if ctx.FormBool("count_only") { + if count, err := repo_model.CountRepository(ctx, opts); err != nil { + log.Error("CountRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) // frontend JS doesn't handle error response (same as below) + } else { + ctx.SetTotalCountHeader(count) + ctx.JSONOK() + } return } - // collect the latest commit of each repo - // at most there are dozens of repos (limited by MaxResponseItems), so it's not a big problem at the moment - repoBranchNames := make(map[int64]string, len(repos)) - for _, repo := range repos { - repoBranchNames[repo.ID] = repo.DefaultBranch - } - - repoIDsToLatestCommitSHAs, err := git_model.FindBranchesByRepoAndBranchName(ctx, repoBranchNames) + repos, count, err := repo_model.SearchRepository(ctx, opts) if err != nil { - log.Error("FindBranchesByRepoAndBranchName: %v", err) + log.Error("SearchRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) return } - // call the database O(1) times to get the commit statuses for all repos - repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoIDsToLatestCommitSHAs, db.ListOptions{}) + ctx.SetTotalCountHeader(count) + + latestCommitStatuses, err := commitstatus_service.FindReposLastestCommitStatuses(ctx, repos) if err != nil { - log.Error("GetLatestCommitStatusForPairs: %v", err) + log.Error("FindReposLastestCommitStatuses: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) return } results := make([]*repo_service.WebSearchRepository, len(repos)) for i, repo := range repos { - latestCommitStatus := git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID]) - results[i] = &repo_service.WebSearchRepository{ Repository: &api.Repository{ ID: repo.ID, @@ -629,8 +665,11 @@ func SearchRepo(ctx *context.Context) { Link: repo.Link(), Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, }, - LatestCommitStatus: latestCommitStatus, - LocaleLatestCommitStatus: latestCommitStatus.LocaleString(ctx.Locale), + } + + if latestCommitStatuses[i] != nil { + results[i].LatestCommitStatus = latestCommitStatuses[i] + results[i].LocaleLatestCommitStatus = latestCommitStatuses[i].LocaleString(ctx.Locale) } } @@ -648,10 +687,8 @@ type branchTagSearchResponse struct { func GetBranchesList(ctx *context.Context) { branchOpts := git_model.FindBranchOptions{ RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: util.OptionalBoolFalse, - ListOptions: db.ListOptions{ - ListAll: true, - }, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, } branches, err := git_model.FindBranchNames(ctx, branchOpts) if err != nil { @@ -683,10 +720,8 @@ func GetTagList(ctx *context.Context) { func PrepareBranchList(ctx *context.Context) { branchOpts := git_model.FindBranchOptions{ RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: util.OptionalBoolFalse, - ListOptions: db.ListOptions{ - ListAll: true, - }, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, } brs, err := git_model.FindBranchNames(ctx, branchOpts) if err != nil { diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index 3c0fa4bc00e..46f02084535 100644 --- a/routers/web/repo/search.go +++ b/routers/web/repo/search.go @@ -5,31 +5,28 @@ package repo import ( "net/http" + "strings" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" code_indexer "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) const tplSearch base.TplName = "repo/search" // Search render repository search page func Search(ctx *context.Context) { - if !setting.Indexer.RepoIndexerEnabled { - ctx.Redirect(ctx.Repo.RepoLink) - return - } - language := ctx.FormTrim("l") keyword := ctx.FormTrim("q") - queryType := ctx.FormTrim("t") - isMatch := queryType == "match" + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) ctx.Data["Keyword"] = keyword ctx.Data["Language"] = language - ctx.Data["queryType"] = queryType + ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["PageIsViewCode"] = true if keyword == "" { @@ -42,25 +39,61 @@ func Search(ctx *context.Context) { page = 1 } - total, searchResults, searchResultLanguages, err := code_indexer.PerformSearch(ctx, []int64{ctx.Repo.Repository.ID}, - language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) - if err != nil { - if code_indexer.IsAvailable(ctx) { - ctx.ServerError("SearchResults", err) - return + var total int + var searchResults []*code_indexer.Result + var searchResultLanguages []*code_indexer.SearchResultLanguages + if setting.Indexer.RepoIndexerEnabled { + var err error + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: []int64{ctx.Repo.Repository.ID}, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + ctx.Data["CodeIndexerUnavailable"] = true + } else { + ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) } - ctx.Data["CodeIndexerUnavailable"] = true } else { - ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ContextLineNumber: 3, IsFuzzy: isFuzzy}) + if err != nil { + ctx.ServerError("GrepSearch", err) + return + } + total = len(res) + pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res)) + pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res)) + res = res[pageStart:pageEnd] + for _, r := range res { + searchResults = append(searchResults, &code_indexer.Result{ + RepoID: ctx.Repo.Repository.ID, + Filename: r.Filename, + CommitID: ctx.Repo.CommitID, + // UpdatedUnix: not supported yet + // Language: not supported yet + // Color: not supported yet + Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")), + }) + } } - ctx.Data["SourcePath"] = ctx.Repo.Repository.Link() + ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["Repo"] = ctx.Repo.Repository ctx.Data["SearchResults"] = searchResults ctx.Data["SearchResultLanguages"] = searchResultLanguages pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "l", "Language") + pager.AddParamString("l", language) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplSearch) diff --git a/routers/web/repo/setting/avatar.go b/routers/web/repo/setting/avatar.go index 02c807b775d..504f57cfc2b 100644 --- a/routers/web/repo/setting/avatar.go +++ b/routers/web/repo/setting/avatar.go @@ -8,11 +8,11 @@ import ( "fmt" "io" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" repo_service "code.gitea.io/gitea/services/repository" ) @@ -38,7 +38,7 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { defer r.Close() if form.Avatar.Size > setting.Avatar.MaxFileSize { - return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) } data, err := io.ReadAll(r) @@ -47,7 +47,7 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { } st := typesniffer.DetectContentType(data) if !(st.IsImage() && !st.IsSvgImage()) { - return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) } if err = repo_service.UploadAvatar(ctx, ctxRepo, data); err != nil { return fmt.Errorf("UploadAvatar: %w", err) diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index 1e71d33c08d..31f9f76d0f9 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -4,20 +4,19 @@ package setting import ( + "errors" "net/http" "strings" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" @@ -28,7 +27,7 @@ func Collaboration(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.collaboration") ctx.Data["PageIsSettingsCollaboration"] = true - users, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + users, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: ctx.Repo.Repository.ID}) if err != nil { ctx.ServerError("GetCollaborators", err) return @@ -52,7 +51,7 @@ func Collaboration(ctx *context.Context) { // CollaborationPost response for actions for a collaboration of a repository func CollaborationPost(ctx *context.Context) { - name := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("collaborator"))) + name := strings.ToLower(ctx.FormString("collaborator")) if len(name) == 0 || ctx.Repo.Owner.LowerName == name { ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) return @@ -102,7 +101,12 @@ func CollaborationPost(ctx *context.Context) { } if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil { - ctx.ServerError("AddCollaborator", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + } else { + ctx.ServerError("AddCollaborator", err) + } return } @@ -127,10 +131,19 @@ func ChangeCollaborationAccessMode(ctx *context.Context) { // DeleteCollaboration delete a collaboration for a repository func DeleteCollaboration(ctx *context.Context) { - if err := repo_service.DeleteCollaboration(ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { - ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + if collaborator, err := user_model.GetUserByID(ctx, ctx.FormInt64("id")); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + } else { + ctx.ServerError("GetUserByName", err) + return + } } else { - ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { + ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + } } ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") @@ -144,7 +157,7 @@ func AddTeamPost(ctx *context.Context) { return } - name := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("team"))) + name := strings.ToLower(ctx.FormString("team")) if len(name) == 0 { ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") return diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go index 9bf54e706a3..881d148afc4 100644 --- a/routers/web/repo/setting/default_branch.go +++ b/routers/web/repo/setting/default_branch.go @@ -6,13 +6,12 @@ package setting import ( "net/http" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" + git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers/web/repo" - notify_service "code.gitea.io/gitea/services/notify" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" ) // SetDefaultBranchPost set default branch @@ -35,23 +34,14 @@ func SetDefaultBranchPost(ctx *context.Context) { } branch := ctx.FormString("branch") - if !ctx.Repo.GitRepo.IsBranchExist(branch) { - ctx.Status(http.StatusNotFound) - return - } else if repo.DefaultBranch != branch { - repo.DefaultBranch = branch - if err := ctx.Repo.GitRepo.SetDefaultBranch(branch); err != nil { - if !git.IsErrUnsupportedVersion(err) { - ctx.ServerError("SetDefaultBranch", err) - return - } - } - if err := repo_model.UpdateDefaultBranch(ctx, repo); err != nil { + if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { + switch { + case git_model.IsErrBranchNotExist(err): + ctx.Status(http.StatusNotFound) + default: ctx.ServerError("SetDefaultBranch", err) - return } - - notify_service.ChangeDefaultBranch(ctx, repo) + return } log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index 577706d4544..abc3eb4af18 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -8,11 +8,11 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -22,7 +22,7 @@ func DeployKeys(ctx *context.Context) { ctx.Data["PageIsSettingsKeys"] = true ctx.Data["DisableSSH"] = setting.SSH.Disabled - keys, err := asymkey_model.ListDeployKeys(ctx, &asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) if err != nil { ctx.ServerError("ListDeployKeys", err) return @@ -39,7 +39,7 @@ func DeployKeysPost(ctx *context.Context) { ctx.Data["PageIsSettingsKeys"] = true ctx.Data["DisableSSH"] = setting.SSH.Disabled - keys, err := asymkey_model.ListDeployKeys(ctx, &asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) if err != nil { ctx.ServerError("ListDeployKeys", err) return @@ -70,7 +70,7 @@ func DeployKeysPost(ctx *context.Context) { return } - key, err := asymkey_model.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable) + key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable) if err != nil { ctx.Data["HasError"] = true switch { @@ -99,7 +99,7 @@ func DeployKeysPost(ctx *context.Context) { // DeleteDeployKey response for deleting a deploy key func DeleteDeployKey(ctx *context.Context) { - if err := asymkey_service.DeleteDeployKey(ctx.Doer, ctx.FormInt64("id")); err != nil { + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteDeployKey: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) diff --git a/routers/web/repo/setting/git_hooks.go b/routers/web/repo/setting/git_hooks.go index 551327d44b4..217a01c90c1 100644 --- a/routers/web/repo/setting/git_hooks.go +++ b/routers/web/repo/setting/git_hooks.go @@ -6,8 +6,8 @@ package setting import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" ) // GitHooks hooks of a repository diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index d478acdde0d..6dddade066e 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -18,7 +18,6 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/pipeline" "code.gitea.io/gitea/modules/lfs" @@ -28,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) const ( @@ -287,23 +287,20 @@ func LFSFileGet(ctx *context.Context) { st := typesniffer.DetectContentType(buf) ctx.Data["IsTextFile"] = st.IsText() - isRepresentableAsText := st.IsRepresentableAsText() - - fileSize := meta.Size ctx.Data["FileSize"] = meta.Size ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") switch { - case isRepresentableAsText: - if st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - } - - if fileSize >= setting.UI.MaxDisplayFileSize { + case st.IsRepresentableAsText(): + if meta.Size >= setting.UI.MaxDisplayFileSize { ctx.Data["IsFileTooLarge"] = true break } - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + if st.IsSvgImage() { + ctx.Data["IsImageFile"] = true + } + + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) // Building code view blocks with line number on server side. escapedContent := &bytes.Buffer{} @@ -338,6 +335,8 @@ func LFSFileGet(ctx *context.Context) { ctx.Data["IsAudioFile"] = true case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): ctx.Data["IsImageFile"] = true + default: + // TODO: the logic is not the same as "renderFile" in "view.go" } ctx.HTML(http.StatusOK, tplSettingsLFSFile) } @@ -388,20 +387,21 @@ func LFSFileFind(ctx *context.Context) { sha := ctx.FormString("sha") ctx.Data["Title"] = oid ctx.Data["PageIsSettingsLFS"] = true - var hash git.SHA1 + objectFormat := ctx.Repo.GetObjectFormat() + var objectID git.ObjectID if len(sha) == 0 { pointer := lfs.Pointer{Oid: oid, Size: size} - hash = git.ComputeBlobHash([]byte(pointer.StringContent())) - sha = hash.String() + objectID = git.ComputeBlobHash(objectFormat, []byte(pointer.StringContent())) + sha = objectID.String() } else { - hash = git.MustIDFromString(sha) + objectID = git.MustIDFromString(sha) } ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" ctx.Data["Oid"] = oid ctx.Data["Size"] = size ctx.Data["SHA"] = sha - results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, hash) + results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, objectID) if err != nil && err != io.EOF { log.Error("Failure in FindLFSFile: %v", err) ctx.ServerError("LFSFind: FindLFSFile.", err) diff --git a/routers/web/repo/setting/main_test.go b/routers/web/repo/setting/main_test.go index 5a6fa562177..c414b853e51 100644 --- a/routers/web/repo/setting/main_test.go +++ b/routers/web/repo/setting/main_test.go @@ -4,14 +4,11 @@ package setting import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index e0f2294a14f..b30dc3b0614 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -15,9 +15,9 @@ import ( "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" pull_service "code.gitea.io/gitea/services/pull" "code.gitea.io/gitea/services/repository" @@ -68,9 +68,9 @@ func SettingsProtectedBranch(c *context.Context) { } c.Data["PageIsSettingsBranches"] = true - c.Data["Title"] = c.Tr("repo.settings.protected_branch") + " - " + rule.RuleName + c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName - users, err := access_model.GetRepoReaders(c.Repo.Repository) + users, err := access_model.GetRepoReaders(c, c.Repo.Repository) if err != nil { c.ServerError("Repo.Repository.GetReaders", err) return @@ -84,7 +84,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["recent_status_checks"] = contexts if c.Repo.Owner.IsOrganization() { - teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c.Repo.Repository.ID, perm.AccessModeRead) + teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead) if err != nil { c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) return @@ -228,6 +228,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) { protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews protectBranch.BlockOnOfficialReviewRequests = f.BlockOnOfficialReviewRequests protectBranch.DismissStaleApprovals = f.DismissStaleApprovals + protectBranch.IgnoreStaleApprovals = f.IgnoreStaleApprovals protectBranch.RequireSignedCommits = f.RequireSignedCommits protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns protectBranch.UnprotectedFilePatterns = f.UnprotectedFilePatterns @@ -285,7 +286,7 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) { return } - if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository.ID, ruleID); err != nil { + if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, ruleID); err != nil { ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", rule.RuleName)) ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) return diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go index aafbd19e80e..2c25b650b97 100644 --- a/routers/web/repo/setting/protected_tag.go +++ b/routers/web/repo/setting/protected_tag.go @@ -13,9 +13,9 @@ import ( "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -147,7 +147,7 @@ func setTagsContext(ctx *context.Context) error { } ctx.Data["ProtectedTags"] = protectedTags - users, err := access_model.GetRepoReaders(ctx.Repo.Repository) + users, err := access_model.GetRepoReaders(ctx, ctx.Repo.Repository) if err != nil { ctx.ServerError("Repo.Repository.GetReaders", err) return err @@ -155,7 +155,7 @@ func setTagsContext(ctx *context.Context) error { ctx.Data["Users"] = users if ctx.Repo.Owner.IsOrganization() { - teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx.Repo.Repository.ID, perm.AccessModeRead) + teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead) if err != nil { ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) return err diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go index 2c192e9790a..a47d3b45e2c 100644 --- a/routers/web/repo/setting/runners.go +++ b/routers/web/repo/setting/runners.go @@ -11,9 +11,10 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" actions_shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -53,6 +54,11 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { } if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } return &runnersCtx{ RepoID: 0, OwnerID: ctx.Org.Organization.ID, diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index 3d7a0576027..d4d56bfc57e 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -8,9 +8,10 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" shared "code.gitea.io/gitea/routers/web/shared/secrets" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -42,6 +43,11 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { } if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } return &secretsCtx{ OwnerID: ctx.ContextUser.ID, RepoID: 0, diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index af09e240d58..00a5282f34e 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -5,6 +5,7 @@ package setting import ( + "errors" "fmt" "net/http" "strconv" @@ -12,25 +13,26 @@ import ( "time" "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/indexer/stats" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" - repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" @@ -185,7 +187,7 @@ func SettingsPost(ctx *context.Context) { ctx.Redirect(repo.Link() + "/settings") case "mirror": - if !setting.Mirror.Enabled || !repo.IsMirror { + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { ctx.NotFound("", nil) return } @@ -243,6 +245,13 @@ func SettingsPost(ctx *context.Context) { return } + remoteAddress, err := util.SanitizeURL(form.MirrorAddress) + if err != nil { + ctx.ServerError("SanitizeURL", err) + return + } + pullMirror.RemoteAddress = remoteAddress + form.LFS = form.LFS && setting.LFS.StartServer if len(form.LFSEndpoint) > 0 { @@ -271,14 +280,14 @@ func SettingsPost(ctx *context.Context) { ctx.Redirect(repo.Link() + "/settings") case "mirror-sync": - if !setting.Mirror.Enabled || !repo.IsMirror { + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { ctx.NotFound("", nil) return } mirror_service.AddPullMirrorToQueue(repo.ID) - ctx.Flash.Info(ctx.Tr("repo.settings.mirror_sync_in_progress")) + ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) ctx.Redirect(repo.Link() + "/settings") case "push-mirror-sync": @@ -295,11 +304,11 @@ func SettingsPost(ctx *context.Context) { mirror_service.AddPushMirrorToQueue(m.ID) - ctx.Flash.Info(ctx.Tr("repo.settings.mirror_sync_in_progress")) + ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) ctx.Redirect(repo.Link() + "/settings") case "push-mirror-update": - if !setting.Mirror.Enabled { + if !setting.Mirror.Enabled || repo.IsArchived { ctx.NotFound("", nil) return } @@ -336,7 +345,7 @@ func SettingsPost(ctx *context.Context) { ctx.Redirect(repo.Link() + "/settings") case "push-mirror-remove": - if !setting.Mirror.Enabled { + if !setting.Mirror.Enabled || repo.IsArchived { ctx.NotFound("", nil) return } @@ -365,7 +374,7 @@ func SettingsPost(ctx *context.Context) { ctx.Redirect(repo.Link() + "/settings") case "push-mirror-add": - if setting.Mirror.DisableNewPush { + if setting.Mirror.DisableNewPush || repo.IsArchived { ctx.NotFound("", nil) return } @@ -397,14 +406,21 @@ func SettingsPost(ctx *context.Context) { return } - m := &repo_model.PushMirror{ - RepoID: repo.ID, - Repo: repo, - RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), - SyncOnCommit: form.PushMirrorSyncOnCommit, - Interval: interval, + remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) + if err != nil { + ctx.ServerError("SanitizeURL", err) + return } - if err := repo_model.InsertPushMirror(ctx, m); err != nil { + + m := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + SyncOnCommit: form.PushMirrorSyncOnCommit, + Interval: interval, + RemoteAddress: remoteAddress, + } + if err := db.Insert(ctx, m); err != nil { ctx.ServerError("InsertPushMirror", err) return } @@ -474,6 +490,13 @@ func SettingsPost(ctx *context.Context) { } } + if form.DefaultWikiBranch != "" { + if err := wiki_service.ChangeDefaultWikiBranch(ctx, repo, form.DefaultWikiBranch); err != nil { + log.Error("ChangeDefaultWikiBranch failed, err: %v", err) + ctx.Flash.Warning(ctx.Tr("repo.settings.failed_to_change_default_wiki_branch")) + } + } + if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { if !validation.IsValidExternalURL(form.ExternalTrackerURL) { ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) @@ -520,6 +543,9 @@ func SettingsPost(ctx *context.Context) { units = append(units, repo_model.RepoUnit{ RepoID: repo.ID, Type: unit_model.TypeProjects, + Config: &repo_model.ProjectsConfig{ + ProjectsMode: repo_model.ProjectsMode(form.ProjectsMode), + }, }) } else if !unit_model.TypeProjects.UnitGlobalDisabled() { deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) @@ -562,6 +588,7 @@ func SettingsPost(ctx *context.Context) { AllowRebase: form.PullsAllowRebase, AllowRebaseMerge: form.PullsAllowRebaseMerge, AllowSquash: form.PullsAllowSquash, + AllowFastForwardOnly: form.PullsAllowFastForwardOnly, AllowManualMerge: form.PullsAllowManualMerge, AutodetectManualMerge: form.EnableAutodetectManualMerge, AllowRebaseUpdate: form.PullsAllowRebaseUpdate, @@ -580,7 +607,7 @@ func SettingsPost(ctx *context.Context) { return } - if err := repo_model.UpdateRepositoryUnits(repo, units, deleteUnitTypes); err != nil { + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { ctx.ServerError("UpdateRepositoryUnits", err) return } @@ -678,10 +705,10 @@ func SettingsPost(ctx *context.Context) { } repo.IsMirror = false - if _, err := repo_module.CleanUpMigrateInfo(ctx, repo); err != nil { + if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { ctx.ServerError("CleanUpMigrateInfo", err) return - } else if err = repo_model.DeleteMirrorByRepoID(ctx.Repo.Repository.ID); err != nil { + } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil { ctx.ServerError("DeleteMirrorByRepoID", err) return } @@ -747,7 +774,7 @@ func SettingsPost(ctx *context.Context) { } if newOwner.Type == user_model.UserTypeOrganization { - if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx.Doer.ID) { + if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { // The user shouldn't know about this organization ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) return @@ -765,6 +792,8 @@ func SettingsPost(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) } else if models.IsErrRepoTransferInProgress(err) { ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) } else { ctx.ServerError("TransferOwnership", err) } @@ -799,7 +828,7 @@ func SettingsPost(ctx *context.Context) { return } - if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { ctx.ServerError("CancelRepositoryTransfer", err) return } @@ -863,13 +892,17 @@ func SettingsPost(ctx *context.Context) { return } - if err := repo_model.SetArchiveRepoState(repo, true); err != nil { + if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil { log.Error("Tried to archive a repo: %s", err) ctx.Flash.Error(ctx.Tr("repo.settings.archive.error")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") return } + if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -881,13 +914,19 @@ func SettingsPost(ctx *context.Context) { return } - if err := repo_model.SetArchiveRepoState(repo, false); err != nil { + if err := repo_model.SetArchiveRepoState(ctx, repo, false); err != nil { log.Error("Tried to unarchive a repo: %s", err) ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") return } + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 066d2ef2a9f..09586cc68d6 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -14,10 +14,10 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" repo_service "code.gitea.io/gitea/services/repository" diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go index 1005d1d9c61..45b6c0f39a6 100644 --- a/routers/web/repo/setting/variables.go +++ b/routers/web/repo/setting/variables.go @@ -8,15 +8,17 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" shared "code.gitea.io/gitea/routers/web/shared/actions" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( - tplRepoVariables base.TplName = "repo/settings/actions" - tplOrgVariables base.TplName = "org/settings/actions" - tplUserVariables base.TplName = "user/settings/actions" + tplRepoVariables base.TplName = "repo/settings/actions" + tplOrgVariables base.TplName = "org/settings/actions" + tplUserVariables base.TplName = "user/settings/actions" + tplAdminVariables base.TplName = "admin/actions" ) type variablesCtx struct { @@ -25,6 +27,7 @@ type variablesCtx struct { IsRepo bool IsOrg bool IsUser bool + IsGlobal bool VariablesTemplate base.TplName RedirectLink string } @@ -32,6 +35,7 @@ type variablesCtx struct { func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { if ctx.Data["PageIsRepoSettings"] == true { return &variablesCtx{ + OwnerID: 0, RepoID: ctx.Repo.Repository.ID, IsRepo: true, VariablesTemplate: tplRepoVariables, @@ -40,8 +44,14 @@ func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { } if ctx.Data["PageIsOrgSettings"] == true { + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return nil, nil + } return &variablesCtx{ OwnerID: ctx.ContextUser.ID, + RepoID: 0, IsOrg: true, VariablesTemplate: tplOrgVariables, RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", @@ -51,12 +61,23 @@ func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { if ctx.Data["PageIsUserSettings"] == true { return &variablesCtx{ OwnerID: ctx.Doer.ID, + RepoID: 0, IsUser: true, VariablesTemplate: tplUserVariables, RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", }, nil } + if ctx.Data["PageIsAdmin"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: 0, + IsGlobal: true, + VariablesTemplate: tplAdminVariables, + RedirectLink: setting.AppSubURL + "/admin/actions/variables", + }, nil + } + return nil, errors.New("unable to set Variables context") } diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 33ea2c206b6..1a3549fea41 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -12,12 +12,12 @@ import ( "path" "strings" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" webhook_service "code.gitea.io/gitea/services/webhook" @@ -46,7 +47,7 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLinkNew"] = ctx.Repo.RepoLink + "/settings/hooks" ctx.Data["Description"] = ctx.Tr("repo.settings.hooks_desc", "https://docs.gitea.com/usage/webhooks") - ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{RepoID: ctx.Repo.Repository.ID}) + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: ctx.Repo.Repository.ID}) if err != nil { ctx.ServerError("GetWebhooksByRepoID", err) return @@ -150,6 +151,7 @@ func WebhooksNew(ctx *context.Context) { } } ctx.Data["BaseLink"] = orCtx.LinkNew + ctx.Data["BaseLinkNew"] = orCtx.LinkNew ctx.HTML(http.StatusOK, orCtx.NewTemplate) } @@ -300,7 +302,7 @@ func editWebhook(ctx *context.Context, params webhookParams) { if err := w.UpdateEvent(); err != nil { ctx.ServerError("UpdateEvent", err) return - } else if err := webhook.UpdateWebhook(w); err != nil { + } else if err := webhook.UpdateWebhook(ctx, w); err != nil { ctx.ServerError("UpdateWebhook", err) return } @@ -586,12 +588,13 @@ func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { return nil, nil } ctx.Data["BaseLink"] = orCtx.Link + ctx.Data["BaseLinkNew"] = orCtx.LinkNew var w *webhook.Webhook if orCtx.RepoID > 0 { - w, err = webhook.GetWebhookByRepoID(orCtx.RepoID, ctx.ParamsInt64(":id")) + w, err = webhook.GetWebhookByRepoID(ctx, orCtx.RepoID, ctx.ParamsInt64(":id")) } else if orCtx.OwnerID > 0 { - w, err = webhook.GetWebhookByOwnerID(orCtx.OwnerID, ctx.ParamsInt64(":id")) + w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.OwnerID, ctx.ParamsInt64(":id")) } else if orCtx.IsAdmin { w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id")) } @@ -618,7 +621,7 @@ func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { ctx.Data["PackagistHook"] = webhook_service.GetPackagistHook(w) } - ctx.Data["History"], err = w.History(1) + ctx.Data["History"], err = w.History(ctx, 1) if err != nil { ctx.ServerError("History", err) } @@ -643,7 +646,7 @@ func WebHooksEdit(ctx *context.Context) { // TestWebhook test if web hook is work fine func TestWebhook(ctx *context.Context) { hookID := ctx.ParamsInt64(":id") - w, err := webhook.GetWebhookByRepoID(ctx.Repo.Repository.ID, hookID) + w, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hookID) if err != nil { ctx.Flash.Error("GetWebhookByRepoID: " + err.Error()) ctx.Status(http.StatusInternalServerError) @@ -654,8 +657,9 @@ func TestWebhook(ctx *context.Context) { commit := ctx.Repo.Commit if commit == nil { ghost := user_model.NewGhostUser() + objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName) commit = &git.Commit{ - ID: git.MustIDFromString(git.EmptySHA), + ID: objectFormat.EmptyObjectID(), Author: ghost.NewGitSig(), Committer: ghost.NewGitSig(), CommitMessage: "This is a fake commit", @@ -724,7 +728,7 @@ func ReplayWebhook(ctx *context.Context) { // DeleteWebhook delete a webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByRepoID(ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) diff --git a/routers/web/repo/topic.go b/routers/web/repo/topic.go index d22c3c6aa37..d81a695df97 100644 --- a/routers/web/repo/topic.go +++ b/routers/web/repo/topic.go @@ -8,8 +8,8 @@ import ( "strings" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) // TopicsPost response for creating repository @@ -45,7 +45,7 @@ func TopicsPost(ctx *context.Context) { return } - err := repo_model.SaveTopics(ctx.Repo.Repository.ID, validTopics...) + err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...) if err != nil { log.Error("SaveTopics failed: %v", err) ctx.JSON(http.StatusInternalServerError, map[string]any{ diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index c364e7090f7..d11af4669f9 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -7,8 +7,8 @@ import ( "net/http" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" "github.com/go-enry/go-enry/v2" ) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 26e9cedd3ab..8aa9dbb1be3 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -9,6 +9,7 @@ import ( gocontext "context" "encoding/base64" "fmt" + "html/template" "image" "io" "net/http" @@ -34,8 +35,6 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/lfs" @@ -44,10 +43,13 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" + files_service "code.gitea.io/gitea/services/repository/files" "github.com/nektos/act/pkg/model" @@ -157,7 +159,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try return "", readmeFile, nil } -func renderDirectory(ctx *context.Context, treeLink string) { +func renderDirectory(ctx *context.Context) { entries := renderDirectoryFiles(ctx, 1*time.Second) if ctx.Written() { return @@ -174,7 +176,7 @@ func renderDirectory(ctx *context.Context, treeLink string) { return } - renderReadmeFile(ctx, subfolder, readmeFile, treeLink) + renderReadmeFile(ctx, subfolder, readmeFile) } // localizedExtensions prepends the provided language code with and without a @@ -258,7 +260,7 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil } -func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry, readmeTreelink string) { +func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { target := readmeFile if readmeFile != nil && readmeFile.IsLink() { target, _ = readmeFile.FollowLinks() @@ -302,7 +304,7 @@ func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.Tr return } - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) if markupType := markup.Type(readmeFile.Name()); markupType != "" { ctx.Data["IsMarkup"] = true @@ -311,29 +313,65 @@ func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.Tr ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ Ctx: ctx, RelativePath: path.Join(ctx.Repo.TreePath, readmeFile.Name()), // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). - URLPrefix: path.Join(readmeTreelink, subfolder), - Metas: ctx.Repo.Repository.ComposeDocumentMetas(), - GitRepo: ctx.Repo.GitRepo, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Join(ctx.Repo.TreePath, subfolder), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, }, rd) if err != nil { log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) - buf := &bytes.Buffer{} - ctx.Data["EscapeStatus"], _ = charset.EscapeControlStringReader(rd, buf, ctx.Locale) - ctx.Data["FileContent"] = buf.String() + delete(ctx.Data, "IsMarkup") } - } else { + } + + if ctx.Data["IsMarkup"] != true { ctx.Data["IsPlainText"] = true - buf := &bytes.Buffer{} - ctx.Data["EscapeStatus"], err = charset.EscapeControlStringReader(rd, buf, ctx.Locale) + content, err := io.ReadAll(rd) if err != nil { - log.Error("Read failed: %v", err) + log.Error("Read readme content failed: %v", err) } + contentEscaped := template.HTMLEscapeString(util.UnsafeBytesToString(content)) + ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) + } - ctx.Data["FileContent"] = buf.String() + if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + ctx.Data["CanEditReadmeFile"] = true } } -func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink string) { +func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { + // Show latest commit info of repository in table header, + // or of directory if not in root directory. + ctx.Data["LatestCommit"] = latestCommit + if latestCommit != nil { + + verification := asymkey_model.ParseCommitWithSignature(ctx, latestCommit) + + if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { + return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) + }, nil); err != nil { + ctx.ServerError("CalculateTrustStatus", err) + return false + } + ctx.Data["LatestCommitVerification"] = verification + ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + + statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) + if err != nil { + log.Error("GetLatestCommitStatus: %v", err) + } + + ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses) + ctx.Data["LatestCommitStatuses"] = statuses + } + + return true +} + +func renderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["IsViewFile"] = true ctx.Data["HideRepoInfo"] = true blob := entry.Blob() @@ -347,7 +385,17 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefName) ctx.Data["FileIsSymlink"] = entry.IsLink() ctx.Data["FileName"] = blob.Name() - ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetCommitByPath", err) + return + } + + if !loadLatestCommitData(ctx, commit) { + return + } if ctx.Repo.TreePath == ".editorconfig" { _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) @@ -434,18 +482,18 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st switch { case isRepresentableAsText: + if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["IsFileTooLarge"] = true + break + } + if fInfo.st.IsSvgImage() { ctx.Data["IsImageFile"] = true ctx.Data["CanCopyContent"] = true ctx.Data["HasSourceRenderedToggle"] = true } - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc)) + rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) shouldRenderSource := ctx.FormString("display") == "source" readmeExist := util.IsReadmeFileName(blob.Name()) @@ -469,15 +517,19 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st if !detected { markupType = "" } - metas := ctx.Repo.Repository.ComposeDocumentMetas() + metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) metas["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL() ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ Ctx: ctx, Type: markupType, RelativePath: ctx.Repo.TreePath, - URLPrefix: path.Dir(treeLink), - Metas: metas, - GitRepo: ctx.Repo.GitRepo, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: metas, + GitRepo: ctx.Repo.GitRepo, }, rd) if err != nil { ctx.ServerError("Render", err) @@ -488,8 +540,13 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } else { buf, _ := io.ReadAll(rd) - // empty: 0 lines; "a": one line; "a\n": two lines; "a\nb": two lines; - // the NumLines is only used for the display on the UI: "xxx lines" + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Gitea uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. + // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. + // This NumLines is only used for the display on the UI: "xxx lines" if len(buf) == 0 { ctx.Data["NumLines"] = 0 } else { @@ -497,31 +554,11 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } ctx.Data["NumLinesSet"] = true - language := "" - - indexFilename, worktree, deleteTemporaryFile, err := ctx.Repo.GitRepo.ReadTreeToTemporaryIndex(ctx.Repo.CommitID) - if err == nil { - defer deleteTemporaryFile() - - filename2attribute2info, err := ctx.Repo.GitRepo.CheckAttribute(git.CheckAttributeOpts{ - CachedOnly: true, - Attributes: []string{"linguist-language", "gitlab-language"}, - Filenames: []string{ctx.Repo.TreePath}, - IndexFile: indexFilename, - WorkTree: worktree, - }) - if err != nil { - log.Error("Unable to load attributes for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) - } - - language = filename2attribute2info[ctx.Repo.TreePath]["linguist-language"] - if language == "" || language == "unspecified" { - language = filename2attribute2info[ctx.Repo.TreePath]["gitlab-language"] - } - if language == "unspecified" { - language = "" - } + language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + if err != nil { + log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) } + fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) ctx.Data["LexerName"] = lexerName if err != nil { @@ -569,6 +606,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st break } + // TODO: this logic seems strange, it duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" + // maybe for this case, the file is a binary file, and shouldn't be rendered? if markupType := markup.Type(blob.Name()); markupType != "" { rd := io.MultiReader(bytes.NewReader(buf), dataRc) ctx.Data["IsMarkup"] = true @@ -576,9 +615,13 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, &markup.RenderContext{ Ctx: ctx, RelativePath: ctx.Repo.TreePath, - URLPrefix: path.Dir(treeLink), - Metas: ctx.Repo.Repository.ComposeDocumentMetas(), - GitRepo: ctx.Repo.GitRepo, + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + BranchPath: ctx.Repo.BranchNameSubURL(), + TreePath: path.Dir(ctx.Repo.TreePath), + }, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + GitRepo: ctx.Repo.GitRepo, }, rd) if err != nil { ctx.ServerError("Render", err) @@ -587,6 +630,18 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } } + if ctx.Repo.GitRepo != nil { + checker, deferable := ctx.Repo.GitRepo.CheckAttributeReader(ctx.Repo.CommitID) + if checker != nil { + defer deferable() + attrs, err := checker.CheckPath(ctx.Repo.TreePath) + if err == nil { + ctx.Data["IsVendored"] = git.AttributeToBool(attrs, git.AttributeLinguistVendored).Value() + ctx.Data["IsGenerated"] = git.AttributeToBool(attrs, git.AttributeLinguistGenerated).Value() + } + } + } + if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { img, _, err := image.DecodeConfig(bytes.NewReader(buf)) if err == nil { @@ -611,7 +666,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st } } -func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output string, err error) { +func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) { markupRd, markupWr := io.Pipe() defer markupWr.Close() done := make(chan struct{}) @@ -619,7 +674,7 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i sb := &strings.Builder{} // We allow NBSP here this is rendered escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.RuneNBSP) - output = sb.String() + output = template.HTML(sb.String()) close(done) }() err = markup.Render(renderCtx, input, markupWr) @@ -628,19 +683,10 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i return escaped, output, err } -func safeURL(address string) string { - u, err := url.Parse(address) - if err != nil { - return address - } - u.User = nil - return u.String() -} - func checkHomeCodeViewable(ctx *context.Context) { if len(ctx.Repo.Units) > 0 { if ctx.Repo.Repository.IsBeingCreated() { - task, err := admin_model.GetMigratingTask(ctx.Repo.Repository.ID) + task, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) if err != nil { if admin_model.IsErrTaskDoesNotExist(err) { ctx.Data["Repo"] = ctx.Repo @@ -660,7 +706,7 @@ func checkHomeCodeViewable(ctx *context.Context) { ctx.Data["Repo"] = ctx.Repo ctx.Data["MigrateTask"] = task - ctx.Data["CloneAddr"] = safeURL(cfg.CloneAddr) + ctx.Data["CloneAddr"], _ = util.SanitizeURL(cfg.CloneAddr) ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed ctx.HTML(http.StatusOK, tplMigrating) return @@ -692,7 +738,7 @@ func checkHomeCodeViewable(ctx *context.Context) { } } - ctx.NotFound("Home", fmt.Errorf(ctx.Tr("units.error.no_unit_allowed_repo"))) + ctx.NotFound("Home", fmt.Errorf(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) } func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { @@ -701,7 +747,7 @@ func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { } tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("Repo.Commit.SubTree", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.SubTree", err) return } allEntries, err := tree.ListEntries() @@ -711,24 +757,14 @@ func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) { } for _, entry := range allEntries { if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" { - ctx.Data["CitiationExist"] = true // Read Citation file contents - blob := entry.Blob() - dataRc, err := blob.DataAsync() - if err != nil { - ctx.ServerError("DataAsync", err) - return - } - defer dataRc.Close() - buf := make([]byte, 1024) - n, err := util.ReadAtMost(dataRc, buf) - if err != nil { - ctx.ServerError("ReadAtMost", err) - return + if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil { + log.Error("checkCitationFile: GetBlobContent: %v", err) + } else { + ctx.Data["CitiationExist"] = true + ctx.PageData["citationFileContent"] = content + break } - buf = buf[:n] - ctx.PageData["citationFileContent"] = string(buf) - break } } } @@ -755,7 +791,7 @@ func Home(ctx *context.Context) { return } - renderCode(ctx) + renderHomeCode(ctx) } // LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body @@ -792,7 +828,7 @@ func LastCommit(ctx *context.Context) { func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries { tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("Repo.Commit.SubTree", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.SubTree", err) return nil } @@ -801,12 +837,12 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri // Get current entry user currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return nil } if !entry.IsDir() { - ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return nil } @@ -824,49 +860,21 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri defer cancel() } - selected := make(container.Set[string]) - selected.AddMultiple(ctx.FormStrings("f[]")...) - - entries := allEntries - if len(selected) > 0 { - entries = make(git.Entries, 0, len(selected)) - for _, entry := range allEntries { - if selected.Contains(entry.Name()) { - entries = append(entries, entry) - } - } - } - - var latestCommit *git.Commit - ctx.Data["Files"], latestCommit, err = entries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) + files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) if err != nil { ctx.ServerError("GetCommitsInfo", err) return nil } - - // Show latest commit info of repository in table header, - // or of directory if not in root directory. - ctx.Data["LatestCommit"] = latestCommit - if latestCommit != nil { - - verification := asymkey_model.ParseCommitWithSignature(ctx, latestCommit) - - if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { - return repo_model.IsOwnerMemberCollaborator(ctx.Repo.Repository, user.ID) - }, nil); err != nil { - ctx.ServerError("CalculateTrustStatus", err) - return nil - } - ctx.Data["LatestCommitVerification"] = verification - ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) - - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptions{ListAll: true}) - if err != nil { - log.Error("GetLatestCommitStatus: %v", err) + ctx.Data["Files"] = files + for _, f := range files { + if f.Commit == nil { + ctx.Data["HasFilesWithoutLatestCommit"] = true + break } + } - ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses) - ctx.Data["LatestCommitStatuses"] = statuses + if !loadLatestCommitData(ctx, latestCommit) { + return nil } branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() @@ -883,7 +891,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri } func renderLanguageStats(ctx *context.Context) { - langs, err := repo_model.GetTopLanguageStats(ctx.Repo.Repository, 5) + langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5) if err != nil { ctx.ServerError("Repo.GetTopLanguageStats", err) return @@ -893,7 +901,7 @@ func renderLanguageStats(ctx *context.Context) { } func renderRepoTopics(ctx *context.Context) { - topics, _, err := repo_model.FindTopics(&repo_model.FindTopicOptions{ + topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{ RepoID: ctx.Repo.Repository.ID, }) if err != nil { @@ -903,9 +911,33 @@ func renderRepoTopics(ctx *context.Context) { ctx.Data["Topics"] = topics } -func renderCode(ctx *context.Context) { +func prepareOpenWithEditorApps(ctx *context.Context) { + var tmplApps []map[string]any + apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx) + if len(apps) == 0 { + apps = setting.DefaultOpenWithEditorApps() + } + for _, app := range apps { + schema, _, _ := strings.Cut(app.OpenURL, ":") + var iconHTML template.HTML + if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { + iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-%s", schema), 16, "tw-mr-2") + } else { + iconHTML = svg.RenderHTML("gitea-git", 16, "tw-mr-2") // TODO: it could support user's customized icon in the future + } + tmplApps = append(tmplApps, map[string]any{ + "DisplayName": app.DisplayName, + "OpenURL": app.OpenURL, + "IconHTML": iconHTML, + }) + } + ctx.Data["OpenWithEditorApps"] = tmplApps +} + +func renderHomeCode(ctx *context.Context) { ctx.Data["PageIsViewCode"] = true ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled + prepareOpenWithEditorApps(ctx) if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { showEmpty := true @@ -955,14 +987,6 @@ func renderCode(ctx *context.Context) { } ctx.Data["Title"] = title - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() - treeLink := branchLink - rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() - - if len(ctx.Repo.TreePath) > 0 { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - // Get Topics of this repo renderRepoTopics(ctx) if ctx.Written() { @@ -972,10 +996,12 @@ func renderCode(ctx *context.Context) { // Get current entry user currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { - ctx.NotFoundOrServerError("Repo.Commit.GetTreeEntryByPath", git.IsErrNotExist, err) + HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return } + checkOutdatedBranch(ctx) + checkCitationFile(ctx, entry) if ctx.Written() { return @@ -987,9 +1013,9 @@ func renderCode(ctx *context.Context) { } if entry.IsDir() { - renderDirectory(ctx, treeLink) + renderDirectory(ctx) } else { - renderFile(ctx, entry, treeLink, rawLink) + renderFile(ctx, entry) } if ctx.Written() { return @@ -1030,12 +1056,43 @@ func renderCode(ctx *context.Context) { } ctx.Data["Paths"] = paths + + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + treeLink := branchLink + if len(ctx.Repo.TreePath) > 0 { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + } ctx.Data["TreeLink"] = treeLink ctx.Data["TreeNames"] = treeNames ctx.Data["BranchLink"] = branchLink ctx.HTML(http.StatusOK, tplRepoHome) } +func checkOutdatedBranch(ctx *context.Context) { + if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { + return + } + + // get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName` + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranchCommitID: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName) + if err != nil { + log.Error("GetBranch: %v", err) + // Don't return an error page, as it can be rechecked the next time the user opens the page. + return + } + + if dbBranch.CommitID != commit.ID.String() { + ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true) + } +} + // RenderUserCards render a page show users according the input template func RenderUserCards(ctx *context.Context, total int, getter func(opts db.ListOptions) ([]*user_model.User, error), tpl base.TplName) { page := ctx.FormInt("page") diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index c9cec0313d8..df15f61b173 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -18,8 +18,8 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" notify_service "code.gitea.io/gitea/services/notify" wiki_service "code.gitea.io/gitea/services/wiki" @@ -92,17 +93,32 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) } func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) { - wikiRepo, err := git.OpenRepository(ctx, ctx.Repo.Repository.WikiPath()) - if err != nil { - ctx.ServerError("OpenRepository", err) - return nil, nil, err + wikiGitRepo, errGitRepo := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if errGitRepo != nil { + ctx.ServerError("OpenRepository", errGitRepo) + return nil, nil, errGitRepo } - commit, err := wikiRepo.GetBranchCommit(wiki_service.DefaultBranch) - if err != nil { - return wikiRepo, nil, err + commit, errCommit := wikiGitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultWikiBranch) + if git.IsErrNotExist(errCommit) { + // if the default branch recorded in database is out of sync, then re-sync it + gitRepoDefaultBranch, errBranch := gitrepo.GetWikiDefaultBranch(ctx, ctx.Repo.Repository) + if errBranch != nil { + return wikiGitRepo, nil, errBranch + } + // update the default branch in the database + errDb := repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, DefaultWikiBranch: gitRepoDefaultBranch}, "default_wiki_branch") + if errDb != nil { + return wikiGitRepo, nil, errDb + } + ctx.Repo.Repository.DefaultWikiBranch = gitRepoDefaultBranch + // retry to get the commit from the correct default branch + commit, errCommit = wikiGitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultWikiBranch) } - return wikiRepo, commit, nil + if errCommit != nil { + return wikiGitRepo, nil, errCommit + } + return wikiGitRepo, commit, nil } // wikiContentsByEntry returns the contents of the wiki page referenced by the @@ -238,10 +254,12 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { } rctx := &markup.RenderContext{ - Ctx: ctx, - URLPrefix: ctx.Repo.RepoLink, - Metas: ctx.Repo.Repository.ComposeDocumentMetas(), - IsWiki: true, + Ctx: ctx, + Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx), + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + IsWiki: true, } buf := &strings.Builder{} @@ -313,7 +331,7 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { } // get commit count - wiki revisions - commitsCount, _ := wikiRepo.FileCommitsCount(wiki_service.DefaultBranch, pageFilename) + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) ctx.Data["CommitCount"] = commitsCount return wikiRepo, entry @@ -365,7 +383,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) ctx.Data["footerContent"] = "" // get commit count - wiki revisions - commitsCount, _ := wikiRepo.FileCommitsCount(wiki_service.DefaultBranch, pageFilename) + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) ctx.Data["CommitCount"] = commitsCount // get page @@ -377,7 +395,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) // get Commit Count commitsHistory, err := wikiRepo.CommitsByFileAndRange( git.CommitsByFileAndRangeOptions{ - Revision: wiki_service.DefaultBranch, + Revision: ctx.Repo.Repository.DefaultWikiBranch, File: pageFilename, Page: page, }) @@ -399,20 +417,17 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) func renderEditPage(ctx *context.Context) { wikiRepo, commit, err := findWikiRepoCommit(ctx) - if err != nil { + defer func() { if wikiRepo != nil { - wikiRepo.Close() + _ = wikiRepo.Close() } + }() + if err != nil { if !git.IsErrNotExist(err) { ctx.ServerError("GetBranchCommit", err) } return } - defer func() { - if wikiRepo != nil { - wikiRepo.Close() - } - }() // get requested pagename pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) @@ -581,17 +596,15 @@ func WikiPages(ctx *context.Context) { ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived wikiRepo, commit, err := findWikiRepoCommit(ctx) - if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } - return - } defer func() { if wikiRepo != nil { - wikiRepo.Close() + _ = wikiRepo.Close() } }() + if err != nil { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki") + return + } entries, err := commit.ListEntries() if err != nil { @@ -711,7 +724,7 @@ func NewWikiPost(ctx *context.Context) { wikiName := wiki_service.UserTitleToWebPath("", form.Title) if len(form.Message) == 0 { - form.Message = ctx.Tr("repo.editor.add", form.Title) + form.Message = ctx.Locale.TrString("repo.editor.add", form.Title) } if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Content, form.Message); err != nil { @@ -763,7 +776,7 @@ func EditWikiPost(ctx *context.Context) { newWikiName := wiki_service.UserTitleToWebPath("", form.Title) if len(form.Message) == 0 { - form.Message = ctx.Tr("repo.editor.update", form.Title) + form.Message = ctx.Locale.TrString("repo.editor.update", form.Title) } if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.Content, form.Message); err != nil { diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go index ae050df967a..2894c06fbdb 100644 --- a/routers/web/repo/wiki_test.go +++ b/routers/web/repo/wiki_test.go @@ -9,11 +9,13 @@ import ( "net/url" "testing" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" wiki_service "code.gitea.io/gitea/services/wiki" @@ -26,7 +28,7 @@ const ( ) func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) *git.TreeEntry { - wikiRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) + wikiRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) assert.NoError(t, err) defer wikiRepo.Close() commit, err := wikiRepo.GetBranchCommit("master") @@ -78,7 +80,7 @@ func assertPagesMetas(t *testing.T, expectedNames []string, metas any) { func TestWiki(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_pages") + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") ctx.SetParams("*", "Home") contexttest.LoadRepo(t, ctx, 1) Wiki(ctx) @@ -143,7 +145,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) { }) NewWikiPost(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) - assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page"), ctx.Flash.ErrorMsg) + assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg) assertWikiNotExists(t, ctx.Repo.Repository, "_edit") } @@ -220,3 +222,38 @@ func TestWikiRaw(t *testing.T) { } } } + +func TestDefaultWikiBranch(t *testing.T) { + unittest.PrepareTestEnv(t) + + // repo with no wiki + repoWithNoWiki := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + assert.False(t, repoWithNoWiki.HasWiki()) + assert.NoError(t, wiki_service.ChangeDefaultWikiBranch(db.DefaultContext, repoWithNoWiki, "main")) + + // repo with wiki + assert.NoError(t, repo_model.UpdateRepositoryCols(db.DefaultContext, &repo_model.Repository{ID: 1, DefaultWikiBranch: "wrong-branch"})) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") + ctx.SetParams("*", "Home") + contexttest.LoadRepo(t, ctx, 1) + assert.Equal(t, "wrong-branch", ctx.Repo.Repository.DefaultWikiBranch) + Wiki(ctx) // after the visiting, the out-of-sync database record will update the branch name to "master" + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "master", ctx.Repo.Repository.DefaultWikiBranch) + + // invalid branch name should fail + assert.Error(t, wiki_service.ChangeDefaultWikiBranch(db.DefaultContext, repo, "the bad name")) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "master", repo.DefaultWikiBranch) + + // the same branch name, should succeed (actually a no-op) + assert.NoError(t, wiki_service.ChangeDefaultWikiBranch(db.DefaultContext, repo, "master")) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "master", repo.DefaultWikiBranch) + + // change to another name + assert.NoError(t, wiki_service.ChangeDefaultWikiBranch(db.DefaultContext, repo, "main")) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "main", repo.DefaultWikiBranch) +} diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 7ff1f3e33f5..34b79694427 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -8,42 +8,37 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) // RunnersList prepares data for runners list func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { - count, err := actions_model.CountRunners(ctx, opts) + runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts) if err != nil { ctx.ServerError("CountRunners", err) return } - runners, err := actions_model.FindRunners(ctx, opts) - if err != nil { - ctx.ServerError("FindRunners", err) - return - } - if err := runners.LoadAttributes(ctx); err != nil { + if err := actions_model.RunnerList(runners).LoadAttributes(ctx); err != nil { ctx.ServerError("LoadAttributes", err) return } // ownid=0,repo_id=0,means this token is used for global var token *actions_model.ActionRunnerToken - token, err = actions_model.GetUnactivatedRunnerToken(ctx, opts.OwnerID, opts.RepoID) - if errors.Is(err, util.ErrNotExist) { + token, err = actions_model.GetLatestRunnerToken(ctx, opts.OwnerID, opts.RepoID) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID) if err != nil { ctx.ServerError("CreateRunnerToken", err) return } } else if err != nil { - ctx.ServerError("GetUnactivatedRunnerToken", err) + ctx.ServerError("GetLatestRunnerToken", err) return } @@ -89,18 +84,13 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int RunnerID: runner.ID, } - count, err := actions_model.CountTasks(ctx, opts) + tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts) if err != nil { ctx.ServerError("CountTasks", err) return } - tasks, err := actions_model.FindTasks(ctx, opts) - if err != nil { - ctx.ServerError("FindTasks", err) - return - } - if err = tasks.LoadAttributes(ctx); err != nil { + if err = actions_model.TaskList(tasks).LoadAttributes(ctx); err != nil { ctx.ServerError("TasksLoadAttributes", err) return } diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go index 341c18f589c..79c03e4e8ca 100644 --- a/routers/web/shared/actions/variables.go +++ b/routers/web/shared/actions/variables.go @@ -4,21 +4,17 @@ package actions import ( - "errors" - "regexp" - "strings" - actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/web" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" - secret_service "code.gitea.io/gitea/services/secrets" ) func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { - variables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{ + variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{ OwnerID: ownerID, RepoID: repoID, }) @@ -29,41 +25,16 @@ func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { ctx.Data["Variables"] = variables } -// some regular expression of `variables` and `secrets` -// reference to: -// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables -// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets -var ( - forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") -) - -func envNameCIRegexMatch(name string) error { - if forbiddenEnvNameCIRx.MatchString(name) { - log.Error("Env Name cannot be ci") - return errors.New("env name cannot be ci") - } - return nil -} - func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.EditVariableForm) - if err := secret_service.ValidateName(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - if err := envNameCIRegexMatch(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - v, err := actions_model.InsertVariable(ctx, ownerID, repoID, form.Name, ReserveLineBreakForTextarea(form.Data)) + v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data) if err != nil { - log.Error("InsertVariable error: %v", err) + log.Error("CreateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) return } + ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) ctx.JSONRedirect(redirectURL) } @@ -72,23 +43,8 @@ func UpdateVariable(ctx *context.Context, redirectURL string) { id := ctx.ParamsInt64(":variable_id") form := web.GetForm(ctx).(*forms.EditVariableForm) - if err := secret_service.ValidateName(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - if err := envNameCIRegexMatch(form.Name); err != nil { - ctx.JSONError(err.Error()) - return - } - - ok, err := actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ - ID: id, - Name: strings.ToUpper(form.Name), - Data: ReserveLineBreakForTextarea(form.Data), - }) - if err != nil || !ok { - log.Error("UpdateVariable error: %v", err) + if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok { + log.Error("UpdateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.update.failed")) return } @@ -99,7 +55,7 @@ func UpdateVariable(ctx *context.Context, redirectURL string) { func DeleteVariable(ctx *context.Context, redirectURL string) { id := ctx.ParamsInt64(":variable_id") - if _, err := db.DeleteByBean(ctx, &actions_model.ActionVariable{ID: id}); err != nil { + if err := actions_service.DeleteVariableByID(ctx, id); err != nil { log.Error("Delete variable [%d] failed: %v", id, err) ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) return @@ -107,12 +63,3 @@ func DeleteVariable(ctx *context.Context, redirectURL string) { ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) ctx.JSONRedirect(redirectURL) } - -func ReserveLineBreakForTextarea(input string) string { - // Since the content is from a form which is a textarea, the line endings are \r\n. - // It's a standard behavior of HTML. - // But we want to store them as \n like what GitHub does. - // And users are unlikely to really need to keep the \r. - // Other than this, we should respect the original content, even leading or trailing spaces. - return strings.ReplaceAll(input, "\r\n", "\n") -} diff --git a/routers/web/shared/packages/packages.go b/routers/web/shared/packages/packages.go index 30c25374d1b..57671ad8f14 100644 --- a/routers/web/shared/packages/packages.go +++ b/routers/web/shared/packages/packages.go @@ -12,10 +12,10 @@ import ( packages_model "code.gitea.io/gitea/models/packages" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" cargo_service "code.gitea.io/gitea/services/packages/cargo" container_service "code.gitea.io/gitea/services/packages/container" @@ -157,7 +157,7 @@ func SetRulePreviewContext(ctx *context.Context, owner *user_model.User) { for _, p := range packages { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ PackageID: p.ID, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Sort: packages_model.SortCreatedDesc, Paginator: db.NewAbsoluteListOptions(pcr.KeepCount, 200), }) diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 875cb0cfec7..3bd421f86ab 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -4,17 +4,18 @@ package secrets import ( + "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers/web/shared/actions" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" secret_service "code.gitea.io/gitea/services/secrets" ) func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { - secrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{OwnerID: ownerID, RepoID: repoID}) + secrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{OwnerID: ownerID, RepoID: repoID}) if err != nil { ctx.ServerError("FindSecrets", err) return @@ -26,7 +27,7 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data)) + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) if err != nil { log.Error("CreateOrUpdateSecret failed: %v", err) ctx.JSONError(ctx.Tr("secrets.creation.failed")) diff --git a/routers/web/shared/user/block.go b/routers/web/shared/user/block.go new file mode 100644 index 00000000000..8a2357623f1 --- /dev/null +++ b/routers/web/shared/user/block.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +func BlockedUsers(ctx *context.Context, blocker *user_model.User) { + blocks, _, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ + BlockerID: blocker.ID, + }) + if err != nil { + ctx.ServerError("FindBlockings", err) + return + } + if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Data["UserBlocks"] = blocks +} + +func BlockedUsersPost(ctx *context.Context, blocker *user_model.User) { + form := web.GetForm(ctx).(*forms.BlockUserForm) + if ctx.HasError() { + ctx.ServerError("FormValidation", nil) + return + } + + blockee, err := user_model.GetUserByName(ctx, form.Blockee) + if err != nil { + ctx.ServerError("GetUserByName", nil) + return + } + + switch form.Action { + case "block": + if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note); err != nil { + if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Flash.Error(ctx.Tr("user.block.block.failure", err.Error())) + } else { + ctx.ServerError("BlockUser", err) + return + } + } + case "unblock": + if err := user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee); err != nil { + if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Flash.Error(ctx.Tr("user.block.unblock.failure", err.Error())) + } else { + ctx.ServerError("UnblockUser", err) + return + } + } + case "note": + block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + ctx.ServerError("GetBlocking", err) + return + } + if block != nil { + if err := user_model.UpdateBlockingNote(ctx, block.ID, form.Note); err != nil { + ctx.ServerError("UpdateBlockingNote", err) + return + } + } + } +} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 649537ec635..7531e1ba268 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -4,17 +4,23 @@ package user import ( + "net/url" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" ) // prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu) @@ -30,9 +36,11 @@ func prepareContextForCommonProfile(ctx *context.Context) { func PrepareContextForProfileBigAvatar(ctx *context.Context) { prepareContextForCommonProfile(ctx) - ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate - + if setting.Service.UserLocationMapURL != "" { + ctx.Data["ContextUserLocationMapURL"] = setting.Service.UserLocationMapURL + url.QueryEscape(ctx.ContextUser.Location) + } // Show OpenID URIs openIDs, err := user_model.GetUserOpenIDs(ctx, ctx.ContextUser.ID) if err != nil { @@ -40,13 +48,10 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { return } ctx.Data["OpenIDs"] = openIDs - if len(ctx.ContextUser.Description) != 0 { content, err := markdown.RenderString(&markup.RenderContext{ - URLPrefix: ctx.Repo.RepoLink, - Metas: map[string]string{"mode": "document"}, - GitRepo: ctx.Repo.GitRepo, - Ctx: ctx, + Metas: map[string]string{"mode": "document"}, + Ctx: ctx, }, ctx.ContextUser.Description) if err != nil { ctx.ServerError("RenderString", err) @@ -56,7 +61,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { } showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) - orgs, err := organization.FindOrgs(organization.FindOrgOptions{ + orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ UserID: ctx.ContextUser.ID, IncludePrivate: showPrivate, }) @@ -65,7 +70,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { return } ctx.Data["Orgs"] = orgs - ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(orgs, ctx.Doer) + ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(ctx, orgs, ctx.Doer) badges, _, err := user_model.GetUserBadges(ctx, ctx.ContextUser) if err != nil { @@ -81,22 +86,35 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { if _, ok := ctx.Data["NumFollowing"]; !ok { _, ctx.Data["NumFollowing"], _ = user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) } -} -func FindUserProfileReadme(ctx *context.Context) (profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { - profileDbRepo, err := repo_model.GetRepositoryByName(ctx.ContextUser.ID, ".profile") - if err == nil && !profileDbRepo.IsEmpty && !profileDbRepo.IsPrivate { - if profileGitRepo, err = git.OpenRepository(ctx, profileDbRepo.RepoPath()); err != nil { - log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) + if ctx.Doer != nil { + if block, err := user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + ctx.ServerError("GetBlocking", err) } else { - if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { - log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) + ctx.Data["UserBlocking"] = block + } + } +} + +func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { + profileDbRepo, err := repo_model.GetRepositoryByName(ctx, ctx.ContextUser.ID, ".profile") + if err == nil { + perm, err := access_model.GetUserRepoPermission(ctx, profileDbRepo, doer) + if err == nil && !profileDbRepo.IsEmpty && perm.CanRead(unit.TypeCode) { + if profileGitRepo, err = gitrepo.OpenRepository(ctx, profileDbRepo); err != nil { + log.Error("FindUserProfileReadme failed to OpenRepository: %v", err) } else { - profileReadmeBlob, _ = commit.GetBlobByPath("README.md") + if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil { + log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err) + } else { + profileReadmeBlob, _ = commit.GetBlobByPath("README.md") + } } } + } else if !repo_model.IsErrRepoNotExist(err) { + log.Error("FindUserProfileReadme failed to GetRepositoryByName: %v", err) } - return profileGitRepo, profileReadmeBlob, func() { + return profileDbRepo, profileGitRepo, profileReadmeBlob, func() { if profileGitRepo != nil { _ = profileGitRepo.Close() } @@ -106,7 +124,7 @@ func FindUserProfileReadme(ctx *context.Context) (profileGitRepo *git.Repository func RenderUserHeader(ctx *context.Context) { prepareContextForCommonProfile(ctx) - _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx) + _, _, profileReadmeBlob, profileClose := FindUserProfileReadme(ctx, ctx.Doer) defer profileClose() ctx.Data["HasProfileReadme"] = profileReadmeBlob != nil } @@ -118,7 +136,7 @@ func LoadHeaderCount(ctx *context.Context) error { Actor: ctx.Doer, OwnerID: ctx.ContextUser.ID, Private: ctx.IsSigned, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), IncludeDescription: setting.UI.SearchRepoDescription, }) if err != nil { @@ -126,5 +144,21 @@ func LoadHeaderCount(ctx *context.Context) error { } ctx.Data["RepoCount"] = repoCount + var projectType project_model.Type + if ctx.ContextUser.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + projectCount, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: optional.Some(false), + Type: projectType, + }) + if err != nil { + return err + } + ctx.Data["ProjectCount"] = projectCount + return nil } diff --git a/routers/web/swagger_json.go b/routers/web/swagger_json.go index 493c97aa67a..fc39b504a92 100644 --- a/routers/web/swagger_json.go +++ b/routers/web/swagger_json.go @@ -4,22 +4,10 @@ package web import ( - "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" ) -// tplSwaggerV1Json swagger v1 json template -const tplSwaggerV1Json base.TplName = "swagger/v1_json" - // SwaggerV1Json render swagger v1 json func SwaggerV1Json(ctx *context.Context) { - t, err := ctx.Render.TemplateLookup(string(tplSwaggerV1Json), nil) - if err != nil { - ctx.ServerError("unable to find template", err) - return - } - ctx.Resp.Header().Set("Content-Type", "application/json") - if err = t.Execute(ctx.Resp, ctx.Data); err != nil { - ctx.ServerError("unable to execute template", err) - } + ctx.JSONTemplate("swagger/v1_json") } diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go index 7ad65cd51e3..04f510161db 100644 --- a/routers/web/user/avatar.go +++ b/routers/web/user/avatar.go @@ -9,8 +9,8 @@ import ( "code.gitea.io/gitea/models/avatars" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/services/context" ) func cacheableRedirect(ctx *context.Context, location string) { @@ -27,7 +27,7 @@ func AvatarByUserName(ctx *context.Context) { size := int(ctx.ParamsInt64(":size")) var user *user_model.User - if strings.ToLower(userName) != "ghost" { + if strings.ToLower(userName) != user_model.GhostUserLowerName { var err error if user, err = user_model.GetUserByName(ctx, userName); err != nil { if user_model.IsErrUserNotExist(err) { @@ -47,7 +47,7 @@ func AvatarByUserName(ctx *context.Context) { // AvatarByEmailHash redirects the browser to the email avatar link func AvatarByEmailHash(ctx *context.Context) { hash := ctx.Params(":hash") - email, err := avatars.GetEmailForHash(hash) + email, err := avatars.GetEmailForHash(ctx, hash) if err != nil { ctx.ServerError("invalid avatar hash: "+hash, err) return diff --git a/routers/web/user/code.go b/routers/web/user/code.go index 42d7284189b..785c37b1243 100644 --- a/routers/web/user/code.go +++ b/routers/web/user/code.go @@ -6,12 +6,13 @@ package user import ( "net/http" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" code_indexer "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -39,12 +40,11 @@ func CodeSearch(ctx *context.Context) { language := ctx.FormTrim("l") keyword := ctx.FormTrim("q") - queryType := ctx.FormTrim("t") - isMatch := queryType == "match" + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) ctx.Data["Keyword"] = keyword ctx.Data["Language"] = language - ctx.Data["queryType"] = queryType + ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["IsCodePage"] = true if keyword == "" { @@ -75,7 +75,16 @@ func CodeSearch(ctx *context.Context) { ) if len(repoIDs) > 0 { - total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch) + total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.RepoSearchPagingNum, + }, + }) if err != nil { if code_indexer.IsAvailable(ctx) { ctx.ServerError("SearchResults", err) @@ -100,7 +109,7 @@ func CodeSearch(ctx *context.Context) { } } - repoMaps, err := repo_model.GetRepositoriesMapByIDs(loadRepoIDs) + repoMaps, err := repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) if err != nil { ctx.ServerError("GetRepositoriesMapByIDs", err) return @@ -113,7 +122,7 @@ func CodeSearch(ctx *context.Context) { pager := context.NewPagination(total, setting.UI.RepoSearchPagingNum, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "l", "Language") + pager.AddParamString("l", language) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplUserCode) diff --git a/routers/web/user/home.go b/routers/web/user/home.go index a88479e1293..ff6c2a6c36d 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -24,16 +24,14 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" - context_service "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" @@ -59,7 +57,7 @@ func getDashboardContextUser(ctx *context.Context) *user_model.User { } ctx.Data["ContextUser"] = ctxUser - orgs, err := organization.GetUserOrgsList(ctx.Doer) + orgs, err := organization.GetUserOrgsList(ctx, ctx.Doer) if err != nil { ctx.ServerError("GetUserOrgsList", err) return nil @@ -86,7 +84,7 @@ func Dashboard(ctx *context.Context) { page = 1 } - ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard") + ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Locale.TrString("dashboard") ctx.Data["PageIsDashboard"] = true ctx.Data["PageIsNews"] = true cnt, _ := organization.GetOrganizationCount(ctx, ctxUser) @@ -105,7 +103,7 @@ func Dashboard(ctx *context.Context) { } if setting.Service.EnableUserHeatmap { - data, err := activities_model.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.Doer) + data, err := activities_model.GetUserHeatmapDataByUserTeam(ctx, ctxUser, ctx.Org.Team, ctx.Doer) if err != nil { ctx.ServerError("GetUserHeatmapDataByUserTeam", err) return @@ -135,7 +133,7 @@ func Dashboard(ctx *context.Context) { ctx.Data["Feeds"] = feeds pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5) - pager.AddParam(ctx, "date", "Date") + pager.AddParamString("date", date) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplDashboard) @@ -163,8 +161,8 @@ func Milestones(ctx *context.Context) { Private: true, AllPublic: false, // Include also all public repositories of users and public organisations AllLimited: false, // Include also all public repositories of limited organisations - Archived: util.OptionalBoolFalse, - HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones + Archived: optional.Some(false), + HasMilestones: optional.Some(true), // Just needs display repos has milestones } if ctxUser.IsOrganization() && ctx.Org.Team != nil { @@ -213,13 +211,26 @@ func Milestones(ctx *context.Context) { } } - counts, err := issues_model.CountMilestonesByRepoCondAndKw(userRepoCond, keyword, isShowClosed) + counts, err := issues_model.CountMilestonesMap(ctx, issues_model.FindMilestoneOptions{ + RepoCond: userRepoCond, + Name: keyword, + IsClosed: optional.Some(isShowClosed), + }) if err != nil { ctx.ServerError("CountMilestonesByRepoIDs", err) return } - milestones, err := issues_model.SearchMilestones(repoCond, page, isShowClosed, sortType, keyword) + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.IssuePagingNum, + }, + RepoCond: repoCond, + IsClosed: optional.Some(isShowClosed), + SortType: sortType, + Name: keyword, + }) if err != nil { ctx.ServerError("SearchMilestones", err) return @@ -246,9 +257,11 @@ func Milestones(ctx *context.Context) { } milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{ - URLPrefix: milestones[i].Repo.Link(), - Metas: milestones[i].Repo.ComposeMetas(), - Ctx: ctx, + Links: markup.Links{ + Base: milestones[i].Repo.Link(), + }, + Metas: milestones[i].Repo.ComposeMetas(ctx), + Ctx: ctx, }, milestones[i].Content) if err != nil { ctx.ServerError("RenderString", err) @@ -256,7 +269,7 @@ func Milestones(ctx *context.Context) { } if milestones[i].Repo.IsTimetrackerEnabled(ctx) { - err := milestones[i].LoadTotalTrackedTime() + err := milestones[i].LoadTotalTrackedTime(ctx) if err != nil { ctx.ServerError("LoadTotalTrackedTime", err) return @@ -265,7 +278,7 @@ func Milestones(ctx *context.Context) { i++ } - milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(repoCond, keyword) + milestoneStats, err := issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, repoCond, keyword) if err != nil { ctx.ServerError("GetMilestoneStats", err) return @@ -275,24 +288,24 @@ func Milestones(ctx *context.Context) { if len(repoIDs) == 0 { totalMilestoneStats = milestoneStats } else { - totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(userRepoCond, keyword) + totalMilestoneStats, err = issues_model.GetMilestonesStatsByRepoCondAndKw(ctx, userRepoCond, keyword) if err != nil { ctx.ServerError("GetMilestoneStats", err) return } } - showRepoIds := make(container.Set[int64], len(showRepos)) + showRepoIDs := make(container.Set[int64], len(showRepos)) for _, repo := range showRepos { if repo.ID > 0 { - showRepoIds.Add(repo.ID) + showRepoIDs.Add(repo.ID) } } if len(repoIDs) == 0 { - repoIDs = showRepoIds.Values() + repoIDs = showRepoIDs.Values() } repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool { - return !showRepoIds.Contains(v) + return !showRepoIDs.Contains(v) }) var pagerCount int @@ -316,10 +329,10 @@ func Milestones(ctx *context.Context) { ctx.Data["IsShowClosed"] = isShowClosed pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5) - pager.AddParam(ctx, "q", "Keyword") - pager.AddParam(ctx, "repos", "RepoIDs") - pager.AddParam(ctx, "sort", "SortType") - pager.AddParam(ctx, "state", "State") + pager.AddParamString("q", keyword) + pager.AddParamString("repos", reposQuery) + pager.AddParamString("sort", sortType) + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplMilestones) @@ -335,7 +348,6 @@ func Pulls(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("pull_requests") ctx.Data["PageIsPulls"] = true - ctx.Data["SingleRepoAction"] = "pull" buildIssueOverview(ctx, unit.TypePullRequests) } @@ -349,7 +361,6 @@ func Issues(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("issues") ctx.Data["PageIsIssues"] = true - ctx.Data["SingleRepoAction"] = "issue" buildIssueOverview(ctx, unit.TypeIssues) } @@ -428,9 +439,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { isPullList := unitType == unit.TypePullRequests opts := &issues_model.IssuesOptions{ - IsPull: util.OptionalBoolOf(isPullList), + IsPull: optional.Some(isPullList), SortType: sortType, - IsArchived: util.OptionalBoolFalse, + IsArchived: optional.Some(false), Org: org, Team: team, User: ctx.Doer, @@ -454,16 +465,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { Private: true, AllPublic: false, AllLimited: false, - Collaborate: util.OptionalBoolNone, + Collaborate: optional.None[bool](), UnitType: unitType, - Archived: util.OptionalBoolFalse, + Archived: optional.Some(false), } if team != nil { repoOpts.TeamID = team.ID } accessibleRepos := container.Set[int64]{} { - ids, _, err := repo_model.SearchRepositoryIDs(repoOpts) + ids, _, err := repo_model.SearchRepositoryIDs(ctx, repoOpts) if err != nil { ctx.ServerError("SearchRepositoryIDs", err) return @@ -475,6 +486,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { opts.RepoIDs = []int64{0} } } + if ctx.Doer.ID == ctxUser.ID && filterMode != issues_model.FilterModeYourRepositories { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + opts.AllPublic = true + } switch filterMode { case issues_model.FilterModeAll: @@ -497,15 +515,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Educated guess: Do or don't show closed issues. isShowClosed := ctx.FormString("state") == "closed" - opts.IsClosed = util.OptionalBoolOf(isShowClosed) - - // Filter repos and count issues in them. Count will be used later. - // USING NON-FINAL STATE OF opts FOR A QUERY. - issueCountByRepo, err := issue_indexer.CountIssuesByRepo(ctx, issue_indexer.ToSearchOptions(keyword, opts)) - if err != nil { - ctx.ServerError("CountIssuesByRepo", err) - return - } + opts.IsClosed = optional.Some(isShowClosed) // Make sure page number is at least 1. Will be posted to ctx.Data. page := ctx.FormInt("page") @@ -519,28 +529,14 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Get IDs for labels (a filter option for issues/pulls). // Required for IssuesOptions. - var labelIDs []int64 selectedLabels := ctx.FormString("labels") if len(selectedLabels) > 0 && selectedLabels != "0" { var err error - labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) + opts.LabelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) if err != nil { - ctx.ServerError("StringsToInt64s", err) - return + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) } } - opts.LabelIDs = labelIDs - - // Parse ctx.FormString("repos") and remember matched repo IDs for later. - // Gets set when clicking filters on the issues overview page. - selectedRepoIDs := getRepoIDs(ctx.FormString("repos")) - // Remove repo IDs that are not accessible to the user. - selectedRepoIDs = slices.DeleteFunc(selectedRepoIDs, func(v int64) bool { - return !accessibleRepos.Contains(v) - }) - if len(selectedRepoIDs) > 0 { - opts.RepoIDs = selectedRepoIDs - } // ------------------------------ // Get issues as defined by opts. @@ -562,41 +558,6 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } } - // ---------------------------------- - // Add repository pointers to Issues. - // ---------------------------------- - - // Remove repositories that should not be shown, - // which are repositories that have no issues and are not selected by the user. - selectedRepos := container.SetOf(selectedRepoIDs...) - for k, v := range issueCountByRepo { - if v == 0 && !selectedRepos.Contains(k) { - delete(issueCountByRepo, k) - } - } - - // showReposMap maps repository IDs to their Repository pointers. - showReposMap, err := loadRepoByIDs(ctxUser, issueCountByRepo, unitType) - if err != nil { - if repo_model.IsErrRepoNotExist(err) { - ctx.NotFound("GetRepositoryByID", err) - return - } - ctx.ServerError("loadRepoByIDs", err) - return - } - - // a RepositoryList - showRepos := repo_model.RepositoryListOfMap(showReposMap) - sort.Sort(showRepos) - - // maps pull request IDs to their CommitStatus. Will be posted to ctx.Data. - for _, issue := range issues { - if issue.Repo == nil { - issue.Repo = showReposMap[issue.RepoID] - } - } - commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues) if err != nil { ctx.ServerError("GetIssuesLastCommitStatus", err) @@ -606,7 +567,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // ------------------------------- // Fill stats to post to ctx.Data. // ------------------------------- - issueStats, err := getUserIssueStats(ctx, filterMode, issue_indexer.ToSearchOptions(keyword, opts), ctx.Doer.ID) + issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts)) if err != nil { ctx.ServerError("getUserIssueStats", err) return @@ -619,25 +580,6 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } else { shownIssues = int(issueStats.ClosedCount) } - if len(opts.RepoIDs) != 0 { - shownIssues = 0 - for _, repoID := range opts.RepoIDs { - shownIssues += int(issueCountByRepo[repoID]) - } - } - - var allIssueCount int64 - for _, issueCount := range issueCountByRepo { - allIssueCount += issueCount - } - ctx.Data["TotalIssueCount"] = allIssueCount - - if len(opts.RepoIDs) == 1 { - repo := showReposMap[opts.RepoIDs[0]] - if repo != nil { - ctx.Data["SingleRepoLink"] = repo.Link() - } - } ctx.Data["IsShowClosed"] = isShowClosed @@ -674,12 +616,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { } ctx.Data["CommitLastStatus"] = lastStatus ctx.Data["CommitStatuses"] = commitStatuses - ctx.Data["Repos"] = showRepos - ctx.Data["Counts"] = issueCountByRepo ctx.Data["IssueStats"] = issueStats ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType - ctx.Data["RepoIDs"] = selectedRepoIDs ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["SelectLabels"] = selectedLabels @@ -689,77 +628,22 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["State"] = "open" } - // Convert []int64 to string - reposParam, _ := json.Marshal(opts.RepoIDs) - - ctx.Data["ReposParam"] = string(reposParam) - pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) - pager.AddParam(ctx, "q", "Keyword") - pager.AddParam(ctx, "type", "ViewType") - pager.AddParam(ctx, "repos", "ReposParam") - pager.AddParam(ctx, "sort", "SortType") - pager.AddParam(ctx, "state", "State") - pager.AddParam(ctx, "labels", "SelectLabels") - pager.AddParam(ctx, "milestone", "MilestoneID") - pager.AddParam(ctx, "assignee", "AssigneeID") + pager.AddParamString("q", keyword) + pager.AddParamString("type", viewType) + pager.AddParamString("sort", sortType) + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) + pager.AddParamString("labels", selectedLabels) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplIssues) } -func getRepoIDs(reposQuery string) []int64 { - if len(reposQuery) == 0 || reposQuery == "[]" { - return []int64{} - } - if !issueReposQueryPattern.MatchString(reposQuery) { - log.Warn("issueReposQueryPattern does not match query: %q", reposQuery) - return []int64{} - } - - var repoIDs []int64 - // remove "[" and "]" from string - reposQuery = reposQuery[1 : len(reposQuery)-1] - // for each ID (delimiter ",") add to int to repoIDs - for _, rID := range strings.Split(reposQuery, ",") { - // Ensure nonempty string entries - if rID != "" && rID != "0" { - rIDint64, err := strconv.ParseInt(rID, 10, 64) - if err == nil { - repoIDs = append(repoIDs, rIDint64) - } - } - } - - return repoIDs -} - -func loadRepoByIDs(ctxUser *user_model.User, issueCountByRepo map[int64]int64, unitType unit.Type) (map[int64]*repo_model.Repository, error) { - totalRes := make(map[int64]*repo_model.Repository, len(issueCountByRepo)) - repoIDs := make([]int64, 0, 500) - for id := range issueCountByRepo { - if id <= 0 { - continue - } - repoIDs = append(repoIDs, id) - if len(repoIDs) == 500 { - if err := repo_model.FindReposMapByIDs(repoIDs, totalRes); err != nil { - return nil, err - } - repoIDs = repoIDs[:0] - } - } - if len(repoIDs) > 0 { - if err := repo_model.FindReposMapByIDs(repoIDs, totalRes); err != nil { - return nil, err - } - } - return totalRes, nil -} - // ShowSSHKeys output all the ssh keys of user by uid func ShowSSHKeys(ctx *context.Context) { - keys, err := asymkey_model.ListPublicKeys(ctx.ContextUser.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.ContextUser.ID, + }) if err != nil { ctx.ServerError("ListPublicKeys", err) return @@ -775,7 +659,10 @@ func ShowSSHKeys(ctx *context.Context) { // ShowGPGKeys output all the public GPG keys of user by uid func ShowGPGKeys(ctx *context.Context) { - keys, err := asymkey_model.ListGPGKeys(ctx, ctx.ContextUser.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.ContextUser.ID, + }) if err != nil { ctx.ServerError("ListGPGKeys", err) return @@ -784,7 +671,7 @@ func ShowGPGKeys(ctx *context.Context) { entities := make([]*openpgp.Entity, 0) failedEntitiesID := make([]string, 0) for _, k := range keys { - e, err := asymkey_model.GPGKeyToEntity(k) + e, err := asymkey_model.GPGKeyToEntity(ctx, k) if err != nil { if asymkey_model.IsErrGPGKeyImportNotExist(err) { failedEntitiesID = append(failedEntitiesID, k.KeyID) @@ -821,8 +708,17 @@ func UsernameSubRoute(ctx *context.Context) { username := ctx.Params("username") reloadParam := func(suffix string) (success bool) { ctx.SetParams("username", strings.TrimSuffix(username, suffix)) - context_service.UserAssignmentWeb()(ctx) - return !ctx.Written() + context.UserAssignmentWeb()(ctx) + if ctx.Written() { + return false + } + + // check view permissions + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound("user", fmt.Errorf(ctx.ContextUser.Name)) + return false + } + return true } switch { case strings.HasSuffix(username, ".png"): @@ -843,7 +739,6 @@ func UsernameSubRoute(ctx *context.Context) { return } if reloadParam(".rss") { - context_service.UserAssignmentWeb()(ctx) feed.ShowUserFeedRSS(ctx) } case strings.HasSuffix(username, ".atom"): @@ -855,7 +750,7 @@ func UsernameSubRoute(ctx *context.Context) { feed.ShowUserFeedAtom(ctx) } default: - context_service.UserAssignmentWeb()(ctx) + context.UserAssignmentWeb()(ctx) if !ctx.Written() { ctx.Data["EnableFeed"] = setting.Other.EnableFeed OwnerProfile(ctx) @@ -863,8 +758,15 @@ func UsernameSubRoute(ctx *context.Context) { } } -func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer.SearchOptions, doerID int64) (*issues_model.IssueStats, error) { +func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) { + doerID := ctx.Doer.ID + opts = opts.Copy(func(o *issue_indexer.SearchOptions) { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + o.AllPublic = doerID == ctxUser.ID o.AssigneeID = nil o.PosterID = nil o.MentionID = nil @@ -880,51 +782,54 @@ func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer { openClosedOpts := opts.Copy() switch filterMode { - case issues_model.FilterModeAll, issues_model.FilterModeYourRepositories: + case issues_model.FilterModeAll: + // no-op + case issues_model.FilterModeYourRepositories: + openClosedOpts.AllPublic = false case issues_model.FilterModeAssign: - openClosedOpts.AssigneeID = &doerID + openClosedOpts.AssigneeID = optional.Some(doerID) case issues_model.FilterModeCreate: - openClosedOpts.PosterID = &doerID + openClosedOpts.PosterID = optional.Some(doerID) case issues_model.FilterModeMention: - openClosedOpts.MentionID = &doerID + openClosedOpts.MentionID = optional.Some(doerID) case issues_model.FilterModeReviewRequested: - openClosedOpts.ReviewRequestedID = &doerID + openClosedOpts.ReviewRequestedID = optional.Some(doerID) case issues_model.FilterModeReviewed: - openClosedOpts.ReviewedID = &doerID + openClosedOpts.ReviewedID = optional.Some(doerID) } - openClosedOpts.IsClosed = util.OptionalBoolFalse + openClosedOpts.IsClosed = optional.Some(false) ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) if err != nil { return nil, err } - openClosedOpts.IsClosed = util.OptionalBoolTrue + openClosedOpts.IsClosed = optional.Some(true) ret.ClosedCount, err = issue_indexer.CountIssues(ctx, openClosedOpts) if err != nil { return nil, err } } - ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts) + ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false })) if err != nil { return nil, err } - ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = &doerID })) + ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) })) if err != nil { return nil, err } - ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = &doerID })) + ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) })) if err != nil { return nil, err } - ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = &doerID })) + ret.MentionCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.MentionID = optional.Some(doerID) })) if err != nil { return nil, err } - ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = &doerID })) + ret.ReviewRequestedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewRequestedID = optional.Some(doerID) })) if err != nil { return nil, err } - ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = &doerID })) + ret.ReviewedCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.ReviewedID = optional.Some(doerID) })) if err != nil { return nil, err } diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go index 0be9790548d..1cc9886308c 100644 --- a/routers/web/user/home_test.go +++ b/routers/web/user/home_test.go @@ -7,10 +7,13 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -25,7 +28,7 @@ func TestArchivedIssues(t *testing.T) { ctx.Req.Form.Set("state", "open") // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. - repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{Actor: ctx.Doer}) + repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, &repo_model.SearchRepoOptions{Actor: ctx.Doer}) assert.Len(t, repos, 3) IsArchived := make(map[int64]bool) NumIssues := make(map[int64]int) @@ -44,9 +47,7 @@ func TestArchivedIssues(t *testing.T) { // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) - assert.EqualValues(t, map[int64]int64{50: 1}, ctx.Data["Counts"]) assert.Len(t, ctx.Data["Issues"], 1) - assert.Len(t, ctx.Data["Repos"], 1) } func TestIssues(t *testing.T) { @@ -59,10 +60,8 @@ func TestIssues(t *testing.T) { Issues(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) - assert.EqualValues(t, map[int64]int64{1: 1, 2: 1}, ctx.Data["Counts"]) assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) assert.Len(t, ctx.Data["Issues"], 1) - assert.Len(t, ctx.Data["Repos"], 2) } func TestPulls(t *testing.T) { @@ -116,3 +115,18 @@ func TestMilestonesForSpecificRepo(t *testing.T) { assert.Len(t, ctx.Data["Milestones"], 1) assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2 } + +func TestDashboardPagination(t *testing.T) { + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + page := context.NewPagination(10, 3, 1, 3) + + setting.AppSubURL = "/SubPath" + out, err := ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + assert.NoError(t, err) + assert.Contains(t, out, ``) + + setting.AppSubURL = "" + out, err = ctx.RenderToHTML("base/paginate", map[string]any{"Link": setting.AppSubURL, "Page": page}) + assert.NoError(t, err) + assert.Contains(t, out, ``) +} diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go index 925482a1d2b..8b6ae69296b 100644 --- a/routers/web/user/main_test.go +++ b/routers/web/user/main_test.go @@ -4,14 +4,11 @@ package user import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 579287ffac6..ae0132e6e28 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -16,11 +16,12 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" ) @@ -42,7 +43,10 @@ func GetNotificationCount(ctx *context.Context) { } ctx.Data["NotificationUnreadCount"] = func() int64 { - count, err := activities_model.GetNotificationCount(ctx, ctx.Doer, activities_model.NotificationStatusUnread) + count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) if err != nil { if err != goctx.Canceled { log.Error("Unable to GetNotificationCount for user:%-v: %v", ctx.Doer, err) @@ -89,7 +93,10 @@ func getNotifications(ctx *context.Context) { status = activities_model.NotificationStatusUnread } - total, err := activities_model.GetNotificationCount(ctx, ctx.Doer, status) + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{status}, + }) if err != nil { ctx.ServerError("ErrGetNotificationCount", err) return @@ -103,12 +110,21 @@ func getNotifications(ctx *context.Context) { } statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned} - notifications, err := activities_model.NotificationsForUser(ctx, ctx.Doer, statuses, page, perPage) + nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + ListOptions: db.ListOptions{ + PageSize: perPage, + Page: page, + }, + UserID: ctx.Doer.ID, + Status: statuses, + }) if err != nil { - ctx.ServerError("ErrNotificationsForUser", err) + ctx.ServerError("db.Find[activities_model.Notification]", err) return } + notifications := activities_model.NotificationList(nls) + failCount := 0 repos, failures, err := notifications.LoadRepos(ctx) @@ -128,6 +144,12 @@ func getNotifications(ctx *context.Context) { ctx.ServerError("LoadIssues", err) return } + + if err = notifications.LoadIssuePullRequests(ctx); err != nil { + ctx.ServerError("LoadIssuePullRequests", err) + return + } + notifications = notifications.Without(failures) failCount += len(failures) @@ -217,26 +239,25 @@ func NotificationSubscriptions(ctx *context.Context) { if !util.SliceContainsString([]string{"all", "open", "closed"}, state, true) { state = "all" } + ctx.Data["State"] = state - var showClosed util.OptionalBool + // default state filter is "all" + showClosed := optional.None[bool]() switch state { - case "all": - showClosed = util.OptionalBoolNone case "closed": - showClosed = util.OptionalBoolTrue + showClosed = optional.Some(true) case "open": - showClosed = util.OptionalBoolFalse + showClosed = optional.Some(false) } - var issueTypeBool util.OptionalBool issueType := ctx.FormString("issueType") + // default issue type is no filter + issueTypeBool := optional.None[bool]() switch issueType { case "issues": - issueTypeBool = util.OptionalBoolFalse + issueTypeBool = optional.Some(false) case "pulls": - issueTypeBool = util.OptionalBoolTrue - default: - issueTypeBool = util.OptionalBoolNone + issueTypeBool = optional.Some(true) } ctx.Data["IssueType"] = issueType @@ -247,8 +268,7 @@ func NotificationSubscriptions(ctx *context.Context) { var err error labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ",")) if err != nil { - ctx.ServerError("StringsToInt64s", err) - return + ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true) } } @@ -329,8 +349,8 @@ func NotificationSubscriptions(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current())) return } - pager.AddParam(ctx, "sort", "SortType") - pager.AddParam(ctx, "state", "State") + pager.AddParamString("sort", sortType) + pager.AddParamString("state", state) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplNotificationSubscriptions) @@ -374,6 +394,21 @@ func NotificationWatching(ctx *context.Context) { orderBy = db.SearchOrderByRecentUpdated } + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: setting.UI.User.RepoPagingNum, @@ -384,9 +419,14 @@ func NotificationWatching(ctx *context.Context) { OrderBy: orderBy, Private: ctx.IsSigned, WatchedByID: ctx.Doer.ID, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), TopicOnly: ctx.FormBool("topic"), IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -409,5 +449,15 @@ func NotificationWatching(ctx *context.Context) { // NewAvailable returns the notification counts func NewAvailable(ctx *context.Context) { - ctx.JSON(http.StatusOK, structs.NotificationCount{New: activities_model.CountUnread(ctx, ctx.Doer.ID)}) + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + log.Error("db.Count[activities_model.Notification]", err) + ctx.JSON(http.StatusOK, structs.NotificationCount{New: 0}) + return + } + + ctx.JSON(http.StatusOK, structs.NotificationCount{New: total}) } diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 57770b2b1ae..9af49406c48 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -15,15 +15,17 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" alpine_module "code.gitea.io/gitea/modules/packages/alpine" debian_module "code.gitea.io/gitea/modules/packages/debian" + rpm_module "code.gitea.io/gitea/modules/packages/rpm" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" packages_helper "code.gitea.io/gitea/routers/api/packages/helper" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" packages_service "code.gitea.io/gitea/services/packages" ) @@ -53,7 +55,7 @@ func ListPackages(ctx *context.Context) { OwnerID: ctx.ContextUser.ID, Type: packages_model.Type(packageType), Name: packages_model.SearchValue{Value: query}, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { ctx.ServerError("SearchLatestVersions", err) @@ -123,8 +125,8 @@ func ListPackages(ctx *context.Context) { } pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) - pager.AddParam(ctx, "q", "Query") - pager.AddParam(ctx, "type", "PackageType") + pager.AddParamString("q", query) + pager.AddParamString("type", packageType) ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplPackagesList) @@ -144,7 +146,7 @@ func RedirectToLastVersion(ctx *context.Context) { pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{ PackageID: p.ID, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { ctx.ServerError("GetPackageByName", err) @@ -161,7 +163,7 @@ func RedirectToLastVersion(ctx *context.Context) { return } - ctx.Redirect(pd.FullWebLink()) + ctx.Redirect(pd.VersionWebLink()) } // ViewPackageVersion displays a single package version @@ -195,9 +197,9 @@ func ViewPackageVersion(ctx *context.Context) { } } - ctx.Data["Branches"] = branches.Values() - ctx.Data["Repositories"] = repositories.Values() - ctx.Data["Architectures"] = architectures.Values() + ctx.Data["Branches"] = util.Sorted(branches.Values()) + ctx.Data["Repositories"] = util.Sorted(repositories.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) case packages_model.TypeDebian: distributions := make(container.Set[string]) components := make(container.Set[string]) @@ -216,9 +218,26 @@ func ViewPackageVersion(ctx *context.Context) { } } - ctx.Data["Distributions"] = distributions.Values() - ctx.Data["Components"] = components.Values() - ctx.Data["Architectures"] = architectures.Values() + ctx.Data["Distributions"] = util.Sorted(distributions.Values()) + ctx.Data["Components"] = util.Sorted(components.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) + case packages_model.TypeRpm: + groups := make(container.Set[string]) + architectures := make(container.Set[string]) + + for _, f := range pd.Files { + for _, pp := range f.Properties { + switch pp.Name { + case rpm_module.PropertyGroup: + groups.Add(pp.Value) + case rpm_module.PropertyArchitecture: + architectures.Add(pp.Value) + } + } + } + + ctx.Data["Groups"] = util.Sorted(groups.Values()) + ctx.Data["Architectures"] = util.Sorted(architectures.Values()) } var ( @@ -237,7 +256,7 @@ func ViewPackageVersion(ctx *context.Context) { pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ Paginator: db.NewAbsoluteListOptions(0, 5), PackageID: pd.Package.ID, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) } if err != nil { @@ -341,7 +360,7 @@ func ListPackageVersions(ctx *context.Context) { ExactMatch: false, Value: query, }, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Sort: sort, }) if err != nil { @@ -383,7 +402,7 @@ func PackageSettings(ctx *context.Context) { ctx.Data["IsPackagesPage"] = true ctx.Data["PackageDescriptor"] = pd - repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + repos, _, _ := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: pd.Owner, Private: true, }) @@ -439,7 +458,7 @@ func PackageSettingsPost(ctx *context.Context) { ctx.Redirect(ctx.Link) return case "delete": - err := packages_service.RemovePackageVersion(ctx.Doer, ctx.Package.Descriptor.Version) + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) if err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) @@ -449,7 +468,7 @@ func PackageSettingsPost(ctx *context.Context) { redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" // redirect to the package if there are still versions available - if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID}); has { + if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has { redirectURL = ctx.Package.Descriptor.PackageWebLink() } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 87505b94b19..f0749e10216 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -7,22 +7,30 @@ package user import ( "fmt" "net/http" + "path" "strings" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplProfileBigAvatar base.TplName = "shared/user/profile_big_avatar" + tplFollowUnfollow base.TplName = "org/follow_unfollow" ) // OwnerProfile render profile page for a user or a organization (aka, repo owner) @@ -52,11 +60,10 @@ func userProfile(ctx *context.Context) { ctx.Data["Title"] = ctx.ContextUser.DisplayName() ctx.Data["PageIsUserProfile"] = true - ctx.Data["UserLocationMapURL"] = setting.Service.UserLocationMapURL // prepare heatmap data if setting.Service.EnableUserHeatmap { - data, err := activities_model.GetUserHeatmapDataByUser(ctx.ContextUser, ctx.Doer) + data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) if err != nil { ctx.ServerError("GetUserHeatmapDataByUser", err) return @@ -65,20 +72,21 @@ func userProfile(ctx *context.Context) { ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) } - profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx) + profileDbRepo, profileGitRepo, profileReadmeBlob, profileClose := shared_user.FindUserProfileReadme(ctx, ctx.Doer) defer profileClose() showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) - prepareUserProfileTabData(ctx, showPrivate, profileGitRepo, profileReadmeBlob) + prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileGitRepo, profileReadmeBlob) // call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing shared_user.PrepareContextForProfileBigAvatar(ctx) ctx.HTML(http.StatusOK, tplProfile) } -func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGitRepo *git.Repository, profileReadme *git.Blob) { +func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadme *git.Blob) { // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page + // if there is not a profile readme, the overview tab should be treated as the repositories tab tab := ctx.FormString("tab") - if tab == "" { + if tab == "" || tab == "overview" { if profileReadme != nil { tab = "overview" } else { @@ -154,13 +162,28 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi } ctx.Data["NumFollowing"] = numFollowing + archived := ctx.FormOptionalBool("archived") + ctx.Data["IsArchived"] = archived + + fork := ctx.FormOptionalBool("fork") + ctx.Data["IsFork"] = fork + + mirror := ctx.FormOptionalBool("mirror") + ctx.Data["IsMirror"] = mirror + + template := ctx.FormOptionalBool("template") + ctx.Data["IsTemplate"] = template + + private := ctx.FormOptionalBool("private") + ctx.Data["IsPrivate"] = private + switch tab { case "followers": ctx.Data["Cards"] = followers - total = int(count) + total = int(numFollowers) case "following": ctx.Data["Cards"] = following - total = int(count) + total = int(numFollowing) case "activity": date := ctx.FormString("date") pagingNum = setting.UI.FeedPagingNum @@ -196,10 +219,15 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi OrderBy: orderBy, Private: ctx.IsSigned, StarredByID: ctx.ContextUser.ID, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), TopicOnly: topicOnly, Language: language, IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -218,10 +246,15 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi OrderBy: orderBy, Private: ctx.IsSigned, WatchedByID: ctx.ContextUser.ID, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), TopicOnly: topicOnly, Language: language, IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -236,7 +269,16 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi if profileContent, err := markdown.RenderString(&markup.RenderContext{ Ctx: ctx, GitRepo: profileGitRepo, - Metas: map[string]string{"mode": "document"}, + Links: markup.Links{ + // Give the repo link to the markdown render for the full link of media element. + // the media link usually be like /[user]/[repoName]/media/branch/[branchName], + // Eg. /Tom/.profile/media/branch/main + // The branch shown on the profile page is the default branch, this need to be in sync with doc, see: + // https://docs.gitea.com/usage/profile-readme + Base: profileDbRepo.Link(), + BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)), + }, + Metas: map[string]string{"mode": "document"}, }, bytes); err != nil { log.Error("failed to RenderString: %v", err) } else { @@ -254,10 +296,15 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi OwnerID: ctx.ContextUser.ID, OrderBy: orderBy, Private: ctx.IsSigned, - Collaborate: util.OptionalBoolFalse, + Collaborate: optional.Some(false), TopicOnly: topicOnly, Language: language, IncludeDescription: setting.UI.SearchRepoDescription, + Archived: archived, + Fork: fork, + Mirror: mirror, + Template: template, + IsPrivate: private, }) if err != nil { ctx.ServerError("SearchRepository", err) @@ -277,12 +324,14 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi pager := context.NewPagination(total, pagingNum, page, 5) pager.SetDefaultParams(ctx) - pager.AddParam(ctx, "tab", "TabName") + pager.AddParamString("tab", tab) if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" { - pager.AddParam(ctx, "language", "Language") + pager.AddParamString("language", language) } if tab == "activity" { - pager.AddParam(ctx, "date", "Date") + if ctx.Data["Date"] != nil { + pager.AddParamString("date", fmt.Sprint(ctx.Data["Date"])) + } } ctx.Data["Page"] = pager } @@ -292,15 +341,27 @@ func Action(ctx *context.Context) { var err error switch ctx.FormString("action") { case "follow": - err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser) case "unfollow": - err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) } if err != nil { log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) - ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + if ctx.ContextUser.IsIndividual() { + shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.HTML(http.StatusOK, tplProfileBigAvatar) + return + } else if ctx.ContextUser.IsOrganization() { + ctx.Data["Org"] = ctx.ContextUser + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.HTML(http.StatusOK, tplFollowUnfollow) return } - ctx.JSONOK() + log.Error("Failed to apply action %q: unsupport context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type) + ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) } diff --git a/routers/web/user/search.go b/routers/web/user/search.go index 4d090a3784c..fb7729bbe14 100644 --- a/routers/web/user/search.go +++ b/routers/web/user/search.go @@ -8,7 +8,7 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 5c14f3ad4b5..8ea7548e51d 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -13,12 +13,15 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/password" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/db" + "code.gitea.io/gitea/services/auth/source/smtp" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" "code.gitea.io/gitea/services/user" @@ -53,33 +56,32 @@ func AccountPost(ctx *context.Context) { return } - if len(form.Password) < setting.MinPasswordLength { - ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) - } else if ctx.Doer.IsPasswordSet() && !ctx.Doer.ValidatePassword(form.OldPassword) { + if ctx.Doer.IsPasswordSet() && !ctx.Doer.ValidatePassword(form.OldPassword) { ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) } else if form.Password != form.Retype { ctx.Flash.Error(ctx.Tr("form.password_not_match")) - } else if !password.IsComplexEnough(form.Password) { - ctx.Flash.Error(password.BuildComplexityError(ctx.Locale)) - } else if pwned, err := password.IsPwned(ctx, form.Password); pwned || err != nil { - errMsg := ctx.Tr("auth.password_pwned") - if err != nil { - log.Error(err.Error()) - errMsg = ctx.Tr("auth.password_pwned_err") - } - ctx.Flash.Error(errMsg) } else { - var err error - if err = ctx.Doer.SetPassword(form.Password); err != nil { - ctx.ServerError("UpdateUser", err) - return + opts := &user.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), } - if err := user_model.UpdateUserCols(ctx, ctx.Doer, "salt", "passwd_hash_algo", "passwd"); err != nil { - ctx.ServerError("UpdateUser", err) - return + if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + case errors.Is(err, password.ErrComplexity): + ctx.Flash.Error(password.BuildComplexityError(ctx.Locale)) + case errors.Is(err, password.ErrIsPwned): + ctx.Flash.Error(ctx.Tr("auth.password_pwned")) + case password.IsErrIsPwnedRequest(err): + ctx.Flash.Error(ctx.Tr("auth.password_pwned_err")) + default: + ctx.ServerError("UpdateAuth", err) + return + } + } else { + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) } - log.Trace("User password updated: %s", ctx.Doer.Name) - ctx.Flash.Success(ctx.Tr("settings.change_password_success")) } ctx.Redirect(setting.AppSubURL + "/user/settings/account") @@ -91,9 +93,9 @@ func EmailPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true - // Make emailaddress primary. + // Make email address primary. if ctx.FormString("_method") == "PRIMARY" { - if err := user_model.MakeEmailPrimary(ctx, &user_model.EmailAddress{ID: ctx.FormInt64("id")}); err != nil { + if err := user_model.MakeActiveEmailPrimary(ctx, ctx.FormInt64("id")); err != nil { ctx.ServerError("MakeEmailPrimary", err) return } @@ -105,7 +107,7 @@ func EmailPost(ctx *context.Context) { // Send activation Email if ctx.FormString("_method") == "SENDACTIVATION" { var address string - if setting.CacheService.Enabled && ctx.Cache.IsExist("MailResendLimit_"+ctx.Doer.LowerName) { + if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) { log.Error("Send activation: activation still pending") ctx.Redirect(setting.AppSubURL + "/user/settings/account") return @@ -137,15 +139,14 @@ func EmailPost(ctx *context.Context) { // Only fired when the primary email is inactive (Wrong state) mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) } else { - mailer.SendActivateEmailMail(ctx.Doer, email) + mailer.SendActivateEmailMail(ctx.Doer, email.Email) } address = email.Email - if setting.CacheService.Enabled { - if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { - log.Error("Set cache(MailResendLimit) fail: %v", err) - } + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) ctx.Redirect(setting.AppSubURL + "/user/settings/account") return @@ -161,9 +162,12 @@ func EmailPost(ctx *context.Context) { ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) return } - if err := user_model.SetEmailNotifications(ctx, ctx.Doer, preference); err != nil { + opts := &user.UpdateOptions{ + EmailNotificationsPreference: optional.Some(preference), + } + if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { log.Error("Set Email Notifications failed: %v", err) - ctx.ServerError("SetEmailNotifications", err) + ctx.ServerError("UpdateUser", err) return } log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.Name) @@ -179,49 +183,47 @@ func EmailPost(ctx *context.Context) { return } - email := &user_model.EmailAddress{ - UID: ctx.Doer.ID, - Email: form.Email, - IsActivated: !setting.Service.RegisterEmailConfirm, - } - if err := user_model.AddEmailAddress(ctx, email); err != nil { + if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil { if user_model.IsErrEmailAlreadyUsed(err) { loadAccountData(ctx) ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) - return - } else if user_model.IsErrEmailCharIsNotSupported(err) || - user_model.IsErrEmailInvalid(err) { + } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { loadAccountData(ctx) ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form) - return + } else { + ctx.ServerError("AddEmailAddresses", err) } - ctx.ServerError("AddEmailAddress", err) return } // Send confirmation email if setting.Service.RegisterEmailConfirm { - mailer.SendActivateEmailMail(ctx.Doer, email) - if setting.CacheService.Enabled { - if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { - log.Error("Set cache(MailResendLimit) fail: %v", err) - } + mailer.SendActivateEmailMail(ctx.Doer, form.Email) + if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) } - ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) + + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale))) } else { ctx.Flash.Success(ctx.Tr("settings.add_email_success")) } - log.Trace("Email address added: %s", email.Email) + log.Trace("Email address added: %s", form.Email) ctx.Redirect(setting.AppSubURL + "/user/settings/account") } // DeleteEmail response for delete user's email func DeleteEmail(ctx *context.Context) { - if err := user_model.DeleteEmailAddress(ctx, &user_model.EmailAddress{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { - ctx.ServerError("DeleteEmail", err) + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) + if err != nil || email == nil { + ctx.ServerError("GetEmailAddressByID", err) + return + } + + if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil { + ctx.ServerError("DeleteEmailAddresses", err) return } log.Trace("Email address deleted: %s", ctx.Doer.Name) @@ -232,20 +234,45 @@ func DeleteEmail(ctx *context.Context) { // DeleteAccount render user suicide page and response for delete user himself func DeleteAccount(ctx *context.Context) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) { + ctx.Error(http.StatusNotFound) + return + } + ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { - if user_model.IsErrUserNotExist(err) { + switch { + case user_model.IsErrUserNotExist(err): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil) + case errors.Is(err, smtp.ErrUnsupportedLoginType): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordNotSet{}): + loadAccountData(ctx) + + ctx.RenderWithErr(ctx.Tr("form.unset_password"), tplSettingsAccount, nil) + case errors.As(err, &db.ErrUserPasswordInvalid{}): loadAccountData(ctx) ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) - } else { + default: ctx.ServerError("UserSignIn", err) } return } + // admin should not delete themself + if ctx.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil { switch { case models.IsErrUserOwnRepos(err): @@ -257,6 +284,9 @@ func DeleteAccount(ctx *context.Context) { case models.IsErrUserOwnPackages(err): ctx.Flash.Error(ctx.Tr("form.still_own_packages")) ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrDeleteLastAdminUser(err): + ctx.Flash.Error(ctx.Tr("auth.last_admin")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") default: ctx.ServerError("DeleteUser", err) } @@ -276,7 +306,7 @@ func loadAccountData(ctx *context.Context) { user_model.EmailAddress CanBePrimary bool } - pendingActivation := setting.CacheService.Enabled && ctx.Cache.IsExist("MailResendLimit_"+ctx.Doer.LowerName) + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) emails := make([]*UserEmail, len(emlist)) for i, em := range emlist { var email UserEmail @@ -285,9 +315,10 @@ func loadAccountData(ctx *context.Context) { emails[i] = &email } ctx.Data["Emails"] = emails - ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotifications() + ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference ctx.Data["ActivationsPending"] = pendingActivation ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) if setting.Service.UserDeleteWithCommentsMaxTime != 0 { ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String() diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go index 6742c382e97..9fdc5e4d532 100644 --- a/routers/web/user/setting/account_test.go +++ b/routers/web/user/setting/account_test.go @@ -8,9 +8,9 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go index decb35c1e17..171c1933d4f 100644 --- a/routers/web/user/setting/adopt.go +++ b/routers/web/user/setting/adopt.go @@ -8,9 +8,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index ee44d48dce5..e3822ca988c 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -8,10 +8,11 @@ import ( "net/http" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -88,16 +89,18 @@ func DeleteApplication(ctx *context.Context) { func loadApplicationsData(ctx *context.Context) { ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly - tokens, err := auth_model.ListAccessTokens(ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) if err != nil { ctx.ServerError("ListAccessTokens", err) return } ctx.Data["Tokens"] = tokens - ctx.Data["EnableOAuth2"] = setting.OAuth2.Enable + ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin - if setting.OAuth2.Enable { - ctx.Data["Applications"], err = auth_model.GetOAuth2ApplicationsByUserID(ctx, ctx.Doer.ID) + if setting.OAuth2.Enabled { + ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ + OwnerID: ctx.Doer.ID, + }) if err != nil { ctx.ServerError("GetOAuth2ApplicationsByUserID", err) return diff --git a/routers/web/user/setting/block.go b/routers/web/user/setting/block.go new file mode 100644 index 00000000000..94fc380cee8 --- /dev/null +++ b/routers/web/user/setting/block.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("user.block.list") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + shared_user.BlockedUsers(ctx, ctx.Doer) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +func BlockedUsersPost(ctx *context.Context) { + shared_user.BlockedUsersPost(ctx, ctx.Doer) + if ctx.Written() { + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 2c274f4c1cb..9e969e045dd 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -5,15 +5,17 @@ package setting import ( + "fmt" "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -61,7 +63,7 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") return } - if _, err = asymkey_model.AddPrincipalKey(ctx.Doer.ID, content, 0); err != nil { + if _, err = asymkey_service.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { ctx.Data["HasPrincipalError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err): @@ -77,12 +79,17 @@ func KeysPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "gpg": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + token := asymkey_model.VerificationToken(ctx.Doer, 1) lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) - keys, err := asymkey_model.AddGPGKey(ctx.Doer.ID, form.Content, token, form.Signature) + keys, err := asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, token, form.Signature) if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { - keys, err = asymkey_model.AddGPGKey(ctx.Doer.ID, form.Content, lastToken, form.Signature) + keys, err = asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, lastToken, form.Signature) } if err != nil { ctx.Data["HasGPGError"] = true @@ -131,9 +138,9 @@ func KeysPost(ctx *context.Context) { token := asymkey_model.VerificationToken(ctx.Doer, 1) lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) - keyID, err := asymkey_model.VerifyGPGKey(ctx.Doer.ID, form.KeyID, token, form.Signature) + keyID, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature) if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { - keyID, err = asymkey_model.VerifyGPGKey(ctx.Doer.ID, form.KeyID, lastToken, form.Signature) + keyID, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature) } if err != nil { ctx.Data["HasGPGVerifyError"] = true @@ -153,6 +160,11 @@ func KeysPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + content, err := asymkey_model.CheckPublicKeyString(form.Content) if err != nil { if db.IsErrSSHDisabled(err) { @@ -168,7 +180,7 @@ func KeysPost(ctx *context.Context) { return } - if _, err = asymkey_model.AddPublicKey(ctx.Doer.ID, form.Title, content, 0); err != nil { + if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil { ctx.Data["HasSSHError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err): @@ -192,12 +204,17 @@ func KeysPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "verify_ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + token := asymkey_model.VerificationToken(ctx.Doer, 1) lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) - fingerprint, err := asymkey_model.VerifySSHKey(ctx.Doer.ID, form.Fingerprint, token, form.Signature) + fingerprint, err := asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, token, form.Signature) if err != nil && asymkey_model.IsErrSSHInvalidTokenSignature(err) { - fingerprint, err = asymkey_model.VerifySSHKey(ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature) + fingerprint, err = asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature) } if err != nil { ctx.Data["HasSSHVerifyError"] = true @@ -224,14 +241,23 @@ func KeysPost(ctx *context.Context) { func DeleteKey(ctx *context.Context) { switch ctx.FormString("type") { case "gpg": - if err := asymkey_model.DeleteGPGKey(ctx.Doer, ctx.FormInt64("id")); err != nil { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteGPGKey: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) } case "ssh": + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + keyID := ctx.FormInt64("id") - external, err := asymkey_model.PublicKeyIsExternallyManaged(keyID) + external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID) if err != nil { ctx.ServerError("sshKeysExternalManaged", err) return @@ -241,13 +267,13 @@ func DeleteKey(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") return } - if err := asymkey_service.DeletePublicKey(ctx.Doer, keyID); err != nil { + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil { ctx.Flash.Error("DeletePublicKey: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) } case "principal": - if err := asymkey_service.DeletePublicKey(ctx.Doer, ctx.FormInt64("id")); err != nil { + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeletePublicKey: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success")) @@ -260,32 +286,46 @@ func DeleteKey(ctx *context.Context) { } func loadKeysData(ctx *context.Context) { - keys, err := asymkey_model.ListPublicKeys(ctx.Doer.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: ctx.Doer.ID, + NotKeytype: asymkey_model.KeyTypePrincipal, + }) if err != nil { ctx.ServerError("ListPublicKeys", err) return } ctx.Data["Keys"] = keys - externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(keys) + externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(ctx, keys) if err != nil { ctx.ServerError("ListPublicKeys", err) return } ctx.Data["ExternalKeys"] = externalKeys - gpgkeys, err := asymkey_model.ListGPGKeys(ctx, ctx.Doer.ID, db.ListOptions{}) + gpgkeys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + }) if err != nil { ctx.ServerError("ListGPGKeys", err) return } + if err := asymkey_model.GPGKeyList(gpgkeys).LoadSubKeys(ctx); err != nil { + ctx.ServerError("LoadSubKeys", err) + return + } ctx.Data["GPGKeys"] = gpgkeys tokenToSign := asymkey_model.VerificationToken(ctx.Doer, 1) // generate a new aes cipher using the csrfToken ctx.Data["TokenToSign"] = tokenToSign - principals, err := asymkey_model.ListPrincipalKeys(ctx.Doer.ID, db.ListOptions{}) + principals, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: ctx.Doer.ID, + KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypePrincipal}, + }) if err != nil { ctx.ServerError("ListPrincipalKeys", err) return @@ -294,4 +334,5 @@ func loadKeysData(ctx *context.Context) { ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg") ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh") + ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) } diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go index c3938b32011..e398208d0da 100644 --- a/routers/web/user/setting/main_test.go +++ b/routers/web/user/setting/main_test.go @@ -4,14 +4,11 @@ package setting import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", "..", ".."), - }) + unittest.MainTest(m) } diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go index 93142c21fcf..1f485e06c81 100644 --- a/routers/web/user/setting/oauth2.go +++ b/routers/web/user/setting/oauth2.go @@ -5,8 +5,8 @@ package setting import ( "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) const ( diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index 5786118f50d..85d1e820a51 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -9,10 +9,10 @@ import ( "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -27,9 +27,8 @@ func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) { app := ctx.Data["App"].(*auth.OAuth2Application) ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID) - if ctx.ContextUser.IsOrganization() { - err := shared_user.LoadHeaderCount(ctx) - if err != nil { + if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() { + if err := shared_user.LoadHeaderCount(ctx); err != nil { ctx.ServerError("LoadHeaderCount", err) return } @@ -63,11 +62,12 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { // render the edit page with secret ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true) ctx.Data["App"] = app - ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) if err != nil { ctx.ServerError("GenerateClientSecret", err) return } + oa.renderEditPage(ctx) } @@ -101,7 +101,7 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { // TODO validate redirect URI var err error - if ctx.Data["App"], err = auth.UpdateOAuth2Application(auth.UpdateOAuth2ApplicationOptions{ + if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ ID: ctx.ParamsInt64("id"), Name: form.Name, RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), @@ -131,7 +131,7 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { return } ctx.Data["App"] = app - ctx.Data["ClientSecret"], err = app.GenerateClientSecret() + ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) if err != nil { ctx.ServerError("GenerateClientSecret", err) return @@ -142,7 +142,7 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { // DeleteApp deletes the given oauth2 application func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) { - if err := auth.DeleteOAuth2Application(ctx.ParamsInt64("id"), oa.OwnerID); err != nil { + if err := auth.DeleteOAuth2Application(ctx, ctx.ParamsInt64("id"), oa.OwnerID); err != nil { ctx.ServerError("DeleteOAuth2Application", err) return } diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go index 34d18f999e2..41326594953 100644 --- a/routers/web/user/setting/packages.go +++ b/routers/web/user/setting/packages.go @@ -9,11 +9,11 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" chef_module "code.gitea.io/gitea/modules/packages/chef" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" shared "code.gitea.io/gitea/routers/web/shared/packages" + "code.gitea.io/gitea/services/context" ) const ( diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 2aa6619a499..49eb050dcb6 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -14,20 +14,21 @@ import ( "path/filepath" "strings" + "code.gitea.io/gitea/models/avatars" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" - system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" user_service "code.gitea.io/gitea/services/user" ) @@ -44,82 +45,57 @@ func Profile(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.profile") ctx.Data["PageIsSettingsProfile"] = true ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - ctx.Data["DisableGravatar"] = system_model.GetSettingWithCacheBool(ctx, system_model.KeyPictureDisableGravatar, - setting.GetDefaultDisableGravatar(), - ) + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) ctx.HTML(http.StatusOK, tplSettingsProfile) } -// HandleUsernameChange handle username changes from user settings and admin interface -func HandleUsernameChange(ctx *context.Context, user *user_model.User, newName string) error { - oldName := user.Name - // rename user - if err := user_service.RenameUser(ctx, user, newName); err != nil { - switch { - // Noop as username is not changed - case user_model.IsErrUsernameNotChanged(err): - ctx.Flash.Error(ctx.Tr("form.username_has_not_been_changed")) - // Non-local users are not allowed to change their username. - case user_model.IsErrUserIsNotLocal(err): - ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) - case user_model.IsErrUserAlreadyExist(err): - ctx.Flash.Error(ctx.Tr("form.username_been_taken")) - case user_model.IsErrEmailAlreadyUsed(err): - ctx.Flash.Error(ctx.Tr("form.email_been_used")) - case db.IsErrNameReserved(err): - ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName)) - case db.IsErrNamePatternNotAllowed(err): - ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName)) - case db.IsErrNameCharsNotAllowed(err): - ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", newName)) - default: - ctx.ServerError("ChangeUserName", err) - } - return err - } - log.Trace("User name changed: %s -> %s", oldName, newName) - return nil -} - // ProfilePost response for change user's profile func ProfilePost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.UpdateProfileForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsProfile"] = true ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() - ctx.Data["DisableGravatar"] = system_model.GetSettingWithCacheBool(ctx, system_model.KeyPictureDisableGravatar, - setting.GetDefaultDisableGravatar(), - ) + ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx) if ctx.HasError() { ctx.HTML(http.StatusOK, tplSettingsProfile) return } - if len(form.Name) != 0 && ctx.Doer.Name != form.Name { - log.Debug("Changing name for %s to %s", ctx.Doer.Name, form.Name) - if err := HandleUsernameChange(ctx, ctx.Doer, form.Name); err != nil { + form := web.GetForm(ctx).(*forms.UpdateProfileForm) + + if form.Name != "" { + if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil { + switch { + case user_model.IsErrUserIsNotLocal(err): + ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) + case user_model.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + case db.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name)) + case db.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name)) + case db.IsErrNameCharsNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name)) + default: + ctx.ServerError("RenameUser", err) + return + } ctx.Redirect(setting.AppSubURL + "/user/settings") return } - ctx.Doer.Name = form.Name - ctx.Doer.LowerName = strings.ToLower(form.Name) } - ctx.Doer.FullName = form.FullName - ctx.Doer.KeepEmailPrivate = form.KeepEmailPrivate - ctx.Doer.Website = form.Website - ctx.Doer.Location = form.Location - ctx.Doer.Description = form.Description - ctx.Doer.KeepActivityPrivate = form.KeepActivityPrivate - ctx.Doer.Visibility = form.Visibility - if err := user_model.UpdateUserSetting(ctx, ctx.Doer); err != nil { - if _, ok := err.(user_model.ErrEmailAlreadyUsed); ok { - ctx.Flash.Error(ctx.Tr("form.email_been_used")) - ctx.Redirect(setting.AppSubURL + "/user/settings") - return - } + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + KeepEmailPrivate: optional.Some(form.KeepEmailPrivate), + Description: optional.Some(form.Description), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.Some(form.Visibility), + KeepActivityPrivate: optional.Some(form.KeepActivityPrivate), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { ctx.ServerError("UpdateUser", err) return } @@ -135,7 +111,7 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser * ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal if len(form.Gravatar) > 0 { if form.Avatar != nil { - ctxUser.Avatar = base.EncodeMD5(form.Gravatar) + ctxUser.Avatar = avatars.HashEmail(form.Gravatar) } else { ctxUser.Avatar = "" } @@ -150,7 +126,7 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser * defer fr.Close() if form.Avatar.Size > setting.Avatar.MaxFileSize { - return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024)) } data, err := io.ReadAll(fr) @@ -160,9 +136,9 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser * st := typesniffer.DetectContentType(data) if !(st.IsImage() && !st.IsSvgImage()) { - return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image")) } - if err = user_service.UploadAvatar(ctxUser, data); err != nil { + if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil { return fmt.Errorf("UploadAvatar: %w", err) } } else if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" { @@ -174,7 +150,7 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser * } if err := user_model.UpdateUserCols(ctx, ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { - return fmt.Errorf("UpdateUser: %w", err) + return fmt.Errorf("UpdateUserCols: %w", err) } return nil @@ -194,7 +170,7 @@ func AvatarPost(ctx *context.Context) { // DeleteAvatar render delete avatar page func DeleteAvatar(ctx *context.Context) { - if err := user_service.DeleteAvatar(ctx.Doer); err != nil { + if err := user_service.DeleteAvatar(ctx, ctx.Doer); err != nil { ctx.Flash.Error(err.Error()) } @@ -219,16 +195,12 @@ func Organization(ctx *context.Context) { opts.Page = 1 } - orgs, err := organization.FindOrgs(opts) + orgs, total, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { ctx.ServerError("FindOrgs", err) return } - total, err := organization.CountOrgs(opts) - if err != nil { - ctx.ServerError("CountOrgs", err) - return - } + ctx.Data["Orgs"] = orgs pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) pager.SetDefaultParams(ctx) @@ -292,7 +264,7 @@ func Repos(ctx *context.Context) { return } - userRepos, _, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: ctxUser, Private: true, ListOptions: db.ListOptions{ @@ -317,7 +289,7 @@ func Repos(ctx *context.Context) { ctx.Data["Dirs"] = repoNames ctx.Data["ReposMap"] = repos } else { - repos, count64, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) if err != nil { ctx.ServerError("GetUserRepositories", err) return @@ -379,14 +351,15 @@ func UpdateUIThemePost(ctx *context.Context) { return } - if err := user_model.UpdateUserTheme(ctx, ctx.Doer, form.Theme); err != nil { + opts := &user_service.UpdateOptions{ + Theme: optional.Some(form.Theme), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) - ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") - return + } else { + ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) } - log.Trace("Update user theme: %s", ctx.Doer.Name) - ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") } @@ -396,17 +369,19 @@ func UpdateUserLang(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAppearance"] = true - if len(form.Language) != 0 { + if form.Language != "" { if !util.SliceContainsString(setting.Langs, form.Language) { ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language)) ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") return } - ctx.Doer.Language = form.Language } - if err := user_model.UpdateUserSetting(ctx, ctx.Doer); err != nil { - ctx.ServerError("UpdateUserSetting", err) + opts := &user_service.UpdateOptions{ + Language: optional.Some(form.Language), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.ServerError("UpdateUser", err) return } @@ -414,7 +389,7 @@ func UpdateUserLang(ctx *context.Context) { middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0) log.Trace("User settings updated: %s", ctx.Doer.Name) - ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).Tr("settings.update_language_success")) + ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success")) ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") } diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go index 451fd0ca97a..2bb10cceb95 100644 --- a/routers/web/user/setting/runner.go +++ b/routers/web/user/setting/runner.go @@ -4,8 +4,8 @@ package setting import ( - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) func RedirectToDefaultSetting(ctx *context.Context) { diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 7858b634ce3..cd091023698 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -13,10 +13,10 @@ import ( "strings" "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "github.com/pquerna/otp" diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go index 9a207e149d1..8f788e17356 100644 --- a/routers/web/user/setting/security/openid.go +++ b/routers/web/user/setting/security/openid.go @@ -8,10 +8,10 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/openid" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go index 1ce59fef090..8d6859ab87d 100644 --- a/routers/web/user/setting/security/security.go +++ b/routers/web/user/setting/security/security.go @@ -6,13 +6,16 @@ package security import ( "net/http" + "sort" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" ) const ( @@ -41,7 +44,7 @@ func DeleteAccountLink(ctx *context.Context) { if id <= 0 { ctx.Flash.Error("Account link id is not given") } else { - if _, err := user_model.RemoveAccountLink(ctx.Doer, id); err != nil { + if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { ctx.Flash.Error("RemoveAccountLink: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) @@ -59,21 +62,24 @@ func loadSecurityData(ctx *context.Context) { } ctx.Data["TOTPEnrolled"] = enrolled - credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx.Doer.ID) + credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetWebAuthnCredentialsByUID", err) return } ctx.Data["WebAuthnCredentials"] = credentials - tokens, err := auth_model.ListAccessTokens(ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID}) if err != nil { ctx.ServerError("ListAccessTokens", err) return } ctx.Data["Tokens"] = tokens - accountLinks, err := user_model.ListAccountLinks(ctx.Doer) + accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{ + UserID: ctx.Doer.ID, + OrderBy: "login_source_id DESC", + }) if err != nil { ctx.ServerError("ListAccountLinks", err) return @@ -82,7 +88,7 @@ func loadSecurityData(ctx *context.Context) { // map the provider display name with the AuthSource sources := make(map[*auth_model.Source]string) for _, externalAccount := range accountLinks { - if authSource, err := auth_model.GetSourceByID(externalAccount.LoginSourceID); err == nil { + if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil { var providerDisplayName string type DisplayNamed interface { @@ -105,11 +111,31 @@ func loadSecurityData(ctx *context.Context) { } ctx.Data["AccountLinks"] = sources - orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() + authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{ + IsActive: optional.None[bool](), + LoginType: auth_model.OAuth2, + }) if err != nil { - ctx.ServerError("GetActiveOAuth2Providers", err) + ctx.ServerError("FindSources", err) return } + + var orderedOAuth2Names []string + oauth2Providers := make(map[string]oauth2.Provider) + for _, source := range authSources { + provider, err := oauth2.CreateProviderFromSource(source) + if err != nil { + ctx.ServerError("CreateProviderFromSource", err) + return + } + oauth2Providers[source.Name] = provider + if source.IsActive { + orderedOAuth2Names = append(orderedOAuth2Names, source.Name) + } + } + + sort.Strings(orderedOAuth2Names) + ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names ctx.Data["OAuth2Providers"] = oauth2Providers diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 990e506d6fd..e382c8b9af4 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -11,10 +11,10 @@ import ( "code.gitea.io/gitea/models/auth" wa "code.gitea.io/gitea/modules/auth/webauthn" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "github.com/go-webauthn/webauthn/protocol" @@ -29,7 +29,7 @@ func WebAuthnRegister(ctx *context.Context) { form.Name = strconv.FormatInt(time.Now().UnixNano(), 16) } - cred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, form.Name) + cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name) if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { ctx.ServerError("GetWebAuthnCredentialsByUID", err) return @@ -88,7 +88,7 @@ func WebauthnRegisterPost(ctx *context.Context) { return } - dbCred, err := auth.GetWebAuthnCredentialByName(ctx.Doer.ID, name) + dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name) if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { ctx.ServerError("GetWebAuthnCredentialsByUID", err) return @@ -99,7 +99,7 @@ func WebauthnRegisterPost(ctx *context.Context) { } // Create the credential - _, err = auth.CreateCredential(ctx.Doer.ID, name, cred) + _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) if err != nil { ctx.ServerError("CreateCredential", err) return @@ -112,7 +112,7 @@ func WebauthnRegisterPost(ctx *context.Context) { // WebauthnDelete deletes an security key by id func WebauthnDelete(ctx *context.Context) { form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) - if _, err := auth.DeleteCredential(form.ID, ctx.Doer.ID); err != nil { + if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { ctx.ServerError("GetWebAuthnCredentialByID", err) return } diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go index 04092461fde..4423b627814 100644 --- a/routers/web/user/setting/webhooks.go +++ b/routers/web/user/setting/webhooks.go @@ -6,10 +6,11 @@ package setting import ( "net/http" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) const ( @@ -24,7 +25,7 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks" ctx.Data["Description"] = ctx.Tr("settings.hooks.desc") - ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) + ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) if err != nil { ctx.ServerError("ListWebhooksByOpts", err) return @@ -36,7 +37,7 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByOwnerID(ctx.Doer.ID, ctx.FormInt64("id")); err != nil { + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go index d262c777c30..38f74ea4554 100644 --- a/routers/web/user/stop_watch.go +++ b/routers/web/user/stop_watch.go @@ -8,13 +8,13 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) // GetStopwatches get all stopwatches func GetStopwatches(ctx *context.Context) { - sws, err := issues_model.GetUserStopwatches(ctx.Doer.ID, db.ListOptions{ + sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, db.ListOptions{ Page: ctx.FormInt("page"), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }) @@ -23,13 +23,13 @@ func GetStopwatches(ctx *context.Context) { return } - count, err := issues_model.CountUserStopwatches(ctx.Doer.ID) + count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return } - apiSWs, err := convert.ToStopWatches(sws) + apiSWs, err := convert.ToStopWatches(ctx, sws) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return diff --git a/routers/web/user/task.go b/routers/web/user/task.go index d92bf64af0f..8476767e9e0 100644 --- a/routers/web/user/task.go +++ b/routers/web/user/task.go @@ -8,13 +8,13 @@ import ( "strconv" admin_model "code.gitea.io/gitea/models/admin" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" ) // TaskStatus returns task's status func TaskStatus(ctx *context.Context) { - task, opts, err := admin_model.GetMigratingTaskByID(ctx.ParamsInt64("task"), ctx.Doer.ID) + task, opts, err := admin_model.GetMigratingTaskByID(ctx, ctx.ParamsInt64("task"), ctx.Doer.ID) if err != nil { if admin_model.IsErrTaskDoesNotExist(err) { ctx.JSON(http.StatusNotFound, map[string]any{ @@ -39,7 +39,7 @@ func TaskStatus(ctx *context.Context) { Args: []any{task.Message}, } } - message = ctx.Tr(translatableMessage.Format, translatableMessage.Args...) + message = ctx.Locale.TrString(translatableMessage.Format, translatableMessage.Args...) } ctx.JSON(http.StatusOK, map[string]any{ diff --git a/routers/web/web.go b/routers/web/web.go index 077a0768642..4fff994e424 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -8,9 +8,10 @@ import ( "net/http" "strings" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/metrics" "code.gitea.io/gitea/modules/public" @@ -40,7 +41,7 @@ import ( user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/routers/web/user/setting/security" auth_service "code.gitea.io/gitea/services/auth" - context_service "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/lfs" @@ -58,13 +59,12 @@ const ( GzipMinSize = 1400 ) -// CorsHandler return a http handler who set CORS options if enabled by config -func CorsHandler() func(next http.Handler) http.Handler { +// optionsCorsHandler return a http handler which sets CORS options if enabled by config, it blocks non-CORS OPTIONS requests. +func optionsCorsHandler() func(next http.Handler) http.Handler { + var corsHandler func(next http.Handler) http.Handler if setting.CORSConfig.Enabled { - return cors.Handler(cors.Options{ - // Scheme: setting.CORSConfig.Scheme, // FIXME: the cors middleware needs scheme option - AllowedOrigins: setting.CORSConfig.AllowDomain, - // setting.CORSConfig.AllowSubdomain // FIXME: the cors middleware needs allowSubdomain option + corsHandler = cors.Handler(cors.Options{ + AllowedOrigins: setting.CORSConfig.AllowDomain, AllowedMethods: setting.CORSConfig.Methods, AllowCredentials: setting.CORSConfig.AllowCredentials, AllowedHeaders: setting.CORSConfig.Headers, @@ -73,7 +73,23 @@ func CorsHandler() func(next http.Handler) http.Handler { } return func(next http.Handler) http.Handler { - return next + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + if corsHandler != nil && r.Header.Get("Access-Control-Request-Method") != "" { + corsHandler(next).ServeHTTP(w, r) + } else { + // it should explicitly deny OPTIONS requests if CORS handler is not executed, to avoid the next GET/POST handler being incorrectly called by the OPTIONS request + w.WriteHeader(http.StatusMethodNotAllowed) + } + return + } + // for non-OPTIONS requests, call the CORS handler to add some related headers like "Vary" + if corsHandler != nil { + corsHandler(next).ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) } } @@ -92,7 +108,10 @@ func buildAuthGroup() *auth_service.Group { if setting.Service.EnableReverseProxyAuth { group.Add(&auth_service.ReverseProxy{}) } - specialAdd(group) + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI + } return group } @@ -135,7 +154,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont if ctx.Doer.MustChangePassword { if ctx.Req.URL.Path != "/user/settings/change_password" { if strings.HasPrefix(ctx.Req.UserAgent(), "git") { - ctx.Error(http.StatusUnauthorized, ctx.Tr("auth.must_change_password")) + ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) return } ctx.Data["Title"] = ctx.Tr("auth.must_change_password") @@ -155,7 +174,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont // Redirect to dashboard (or alternate location) if user tries to visit any non-login page. if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { - ctx.RedirectToFirst(ctx.FormString("redirect_to")) + ctx.RedirectToCurrentSite(ctx.FormString("redirect_to")) return } @@ -182,7 +201,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont // Redirect to log in page if auto-signin info is provided and has not signed in. if !options.SignOutRequired && !ctx.IsSigned && - len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 { + ctx.GetSiteCookie(setting.CookieRememberName) != "" { if ctx.Req.URL.Path != "/user/events" { middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) } @@ -213,7 +232,7 @@ func Routes() *web.Route { routes := web.NewRoute() routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler - routes.Methods("GET, HEAD", "/assets/*", CorsHandler(), public.FileHandlerFunc()) + routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc()) routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) @@ -271,6 +290,8 @@ func Routes() *web.Route { return routes } +var ignSignInAndCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) + // registerRoutes register routes func registerRoutes(m *web.Route) { reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) @@ -278,11 +299,11 @@ func registerRoutes(m *web.Route) { // TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView) ignSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) ignExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) - ignSignInAndCsrf := verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) + validation.AddBindingRules() linkAccountEnabled := func(ctx *context.Context) { - if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enable { + if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled { ctx.Error(http.StatusForbidden) return } @@ -410,7 +431,7 @@ func registerRoutes(m *web.Route) { m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo_setting.PackagistHooksEditPost) } - addSettingVariablesRoutes := func() { + addSettingsVariablesRoutes := func() { m.Group("/variables", func() { m.Get("", repo_setting.Variables) m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate) @@ -451,8 +472,9 @@ func registerRoutes(m *web.Route) { m.Get("/change-password", func(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/account") }) - m.Any("/*", CorsHandler(), public.FileHandlerFunc()) - }, CorsHandler()) + m.Get("/passkey-endpoints", passkeyEndpoints) + m.Methods("GET, HEAD", "/*", public.FileHandlerFunc()) + }, optionsCorsHandler()) m.Group("/explore", func() { m.Get("", func(ctx *context.Context) { @@ -525,10 +547,11 @@ func registerRoutes(m *web.Route) { // TODO manage redirection m.Post("/authorize", web.Bind(forms.AuthorizationForm{}), auth.AuthorizeOAuth) }, ignSignInAndCsrf, reqSignIn) - m.Get("/login/oauth/userinfo", ignSignInAndCsrf, auth.InfoOAuth) - m.Post("/login/oauth/access_token", CorsHandler(), web.Bind(forms.AccessTokenForm{}), ignSignInAndCsrf, auth.AccessTokenOAuth) - m.Get("/login/oauth/keys", ignSignInAndCsrf, auth.OIDCKeys) - m.Post("/login/oauth/introspect", CorsHandler(), web.Bind(forms.IntrospectTokenForm{}), ignSignInAndCsrf, auth.IntrospectOAuth) + + m.Methods("GET, OPTIONS", "/login/oauth/userinfo", optionsCorsHandler(), ignSignInAndCsrf, auth.InfoOAuth) + m.Methods("POST, OPTIONS", "/login/oauth/access_token", optionsCorsHandler(), web.Bind(forms.AccessTokenForm{}), ignSignInAndCsrf, auth.AccessTokenOAuth) + m.Methods("GET, OPTIONS", "/login/oauth/keys", optionsCorsHandler(), ignSignInAndCsrf, auth.OIDCKeys) + m.Methods("POST, OPTIONS", "/login/oauth/introspect", optionsCorsHandler(), web.Bind(forms.IntrospectTokenForm{}), ignSignInAndCsrf, auth.IntrospectOAuth) m.Group("/user/settings", func() { m.Get("", user_setting.Profile) @@ -607,7 +630,7 @@ func registerRoutes(m *web.Route) { m.Get("", user_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() - addSettingVariablesRoutes() + addSettingsVariablesRoutes() }, actions.MustEnableActions) m.Get("/organization", user_setting.Organization) @@ -624,6 +647,11 @@ func registerRoutes(m *web.Route) { }) addWebhookEditRoutes() }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) + }) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { @@ -653,12 +681,16 @@ func registerRoutes(m *web.Route) { // ***** START: Admin ***** m.Group("/admin", func() { m.Get("", admin.Dashboard) + m.Get("/system_status", admin.SystemStatus) m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) + m.Get("/self_check", admin.SelfCheck) + m.Group("/config", func() { m.Get("", admin.Config) m.Post("", admin.ChangeConfig) m.Post("/test_mail", admin.SendTestMail) + m.Get("/settings", admin.ConfigSettings) }) m.Group("/monitor", func() { @@ -743,7 +775,7 @@ func registerRoutes(m *web.Route) { m.Post("/delete", admin.DeleteApplication) }) }, func(ctx *context.Context) { - if !setting.OAuth2.Enable { + if !setting.OAuth2.Enabled { ctx.Error(http.StatusForbidden) return } @@ -752,16 +784,17 @@ func registerRoutes(m *web.Route) { m.Group("/actions", func() { m.Get("", admin.RedirectToDefaultSetting) addSettingsRunnersRoutes() + addSettingsVariablesRoutes() }) - }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enable, "EnablePackages", setting.Packages.Enabled)) + }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) // ***** END: Admin ***** m.Group("", func() { m.Get("/{username}", user.UsernameSubRoute) - m.Get("/attachments/{uuid}", repo.GetAttachment) + m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment) }, ignSignIn) - m.Post("/{username}", reqSignIn, context_service.UserAssignmentWeb(), user.Action) + m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action) reqRepoAdmin := context.RequireRepoAdmin() reqRepoCodeWriter := context.RequireRepoWriter(unit.TypeCode) @@ -787,6 +820,24 @@ func registerRoutes(m *web.Route) { } } + individualPermsChecker := func(ctx *context.Context) { + // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. + if ctx.ContextUser.IsIndividual() { + switch { + case ctx.ContextUser.Visibility == structs.VisibleTypePrivate: + if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { + ctx.NotFound("Visit Project", nil) + return + } + case ctx.ContextUser.Visibility == structs.VisibleTypeLimited: + if ctx.Doer == nil { + ctx.NotFound("Visit Project", nil) + return + } + } + } + } + // ***** START: Organization ***** m.Group("/org", func() { m.Group("/{org}", func() { @@ -847,7 +898,7 @@ func registerRoutes(m *web.Route) { m.Post("/delete", org.DeleteOAuth2Application) }) }, func(ctx *context.Context) { - if !setting.OAuth2.Enable { + if !setting.OAuth2.Enabled { ctx.Error(http.StatusForbidden) return } @@ -876,7 +927,7 @@ func registerRoutes(m *web.Route) { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() - addSettingVariablesRoutes() + addSettingsVariablesRoutes() }, actions.MustEnableActions) m.Methods("GET,POST", "/delete", org.SettingsDelete) @@ -899,7 +950,12 @@ func registerRoutes(m *web.Route) { m.Post("/rebuild", org.RebuildCargoIndex) }) }, packagesEnabled) - }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enable, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) + + m.Group("/blocked_users", func() { + m.Get("", org.BlockedUsers) + m.Post("", web.Bind(forms.BlockUserForm{}), org.BlockedUsersPost) + }) + }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) }, context.OrgAssignment(true, true)) }, reqSignIn) // ***** END: Organization ***** @@ -910,10 +966,6 @@ func registerRoutes(m *web.Route) { m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost) m.Get("/migrate", repo.Migrate) m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost) - m.Group("/fork", func() { - m.Combo("/{repoid}").Get(repo.Fork). - Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost) - }, context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader) m.Get("/search", repo.SearchRepo) }, reqSignIn) @@ -956,7 +1008,6 @@ func registerRoutes(m *web.Route) { m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) m.Delete("", org.DeleteProjectBoard) m.Post("/default", org.SetDefaultProjectBoard) - m.Post("/unsetdefault", org.UnsetDefaultProjectBoard) m.Post("/move", org.MoveIssues) }) @@ -967,15 +1018,12 @@ func registerRoutes(m *web.Route) { return } }) - }) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) m.Group("", func() { m.Get("/code", user.CodeSearch) - }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false)) - }, ignSignIn, context_service.UserAssignmentWeb(), context.OrgAssignment()) // for "/{username}/-" (packages, projects, code) - - // ***** Release Attachment Download without Signin - m.Get("/{username}/{reponame}/releases/download/{vTag}/{fileName}", ignSignIn, context.RepoAssignment, repo.MustBeNotEmpty, repo.RedirectDownload) + }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker) + }, ignSignIn, context.UserAssignmentWeb(), context.OrgAssignment()) // for "/{username}/-" (packages, projects, code) m.Group("/{username}/{reponame}", func() { m.Group("/settings", func() { @@ -1058,7 +1106,7 @@ func registerRoutes(m *web.Route) { m.Get("", repo_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() - addSettingVariablesRoutes() + addSettingsVariablesRoutes() }, actions.MustEnableActions) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed m.Group("/migrate", func() { @@ -1194,7 +1242,7 @@ func registerRoutes(m *web.Route) { Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) - m.Combo("/_cherrypick/{sha:([a-f0-9]{7,40})}/*").Get(repo.CherryPick). + m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) }, repo.MustBeEditable) m.Group("", func() { @@ -1212,6 +1260,8 @@ func registerRoutes(m *web.Route) { m.Post("/delete", repo.DeleteBranchPost) m.Post("/restore", repo.RestoreBranchPost) }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + + m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork).Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost) }, reqSignIn, context.RepoAssignment, context.UnitTypes()) // Tags @@ -1236,8 +1286,9 @@ func registerRoutes(m *web.Route) { m.Get(".rss", feedEnabled, repo.ReleasesFeedRSS) m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom) }, ctxDataSet("EnableFeed", setting.Other.EnableFeed), - repo.MustBeNotEmpty, reqRepoReleaseReader, context.RepoRefByType(context.RepoRefTag, true)) - m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, reqRepoReleaseReader, repo.GetAttachment) + repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefTag, true)) + m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment) + m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload) m.Group("/releases", func() { m.Get("/new", repo.NewRelease) m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) @@ -1296,13 +1347,12 @@ func registerRoutes(m *web.Route) { m.Put("", web.Bind(forms.EditProjectBoardForm{}), repo.EditProjectBoard) m.Delete("", repo.DeleteProjectBoard) m.Post("/default", repo.SetDefaultProjectBoard) - m.Post("/unsetdefault", repo.UnSetDefaultProjectBoard) m.Post("/move", repo.MoveIssues) }) }) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) - }, reqRepoProjectsReader, repo.MustEnableProjects) + }, reqRepoProjectsReader, repo.MustEnableRepoProjects) m.Group("/actions", func() { m.Get("", actions.List) @@ -1322,10 +1372,14 @@ func registerRoutes(m *web.Route) { }) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) m.Post("/approve", reqRepoActionsWriter, actions.Approve) - m.Post("/artifacts", actions.ArtifactsView) + m.Get("/artifacts", actions.ArtifactsView) m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) + m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) }) + m.Group("/workflows/{workflow_name}", func() { + m.Get("/badge.svg", actions.GetWorkflowBadge) + }) }, reqRepoActionsReader, actions.MustEnableActions) m.Group("/wiki", func() { @@ -1335,8 +1389,8 @@ func registerRoutes(m *web.Route) { m.Combo("/*"). Get(repo.Wiki). Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), repo.WikiPost) - m.Get("/commit/{sha:[a-f0-9]{7,40}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) - m.Get("/commit/{sha:[a-f0-9]{7,40}}.{ext:patch|diff}", repo.RawDiff) + m.Get("/commit/{sha:[a-f0-9]{7,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:[a-f0-9]{7,64}}.{ext:patch|diff}", repo.RawDiff) }, repo.MustEnableWiki, func(ctx *context.Context) { ctx.Data["PageIsWiki"] = true ctx.Data["CloneButtonOriginLink"] = ctx.Repo.Repository.WikiCloneLink() @@ -1349,6 +1403,18 @@ func registerRoutes(m *web.Route) { m.Group("/activity", func() { m.Get("", repo.Activity) m.Get("/{period}", repo.Activity) + m.Group("/contributors", func() { + m.Get("", repo.Contributors) + m.Get("/data", repo.ContributorsData) + }) + m.Group("/code-frequency", func() { + m.Get("", repo.CodeFrequency) + m.Get("/data", repo.CodeFrequencyData) + }) + m.Group("/recent-commits", func() { + m.Get("", repo.RecentCommits) + m.Get("/data", repo.RecentCommitsData) + }) }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) m.Group("/activity_author_data", func() { @@ -1456,9 +1522,9 @@ func registerRoutes(m *web.Route) { m.Group("", func() { m.Get("/graph", repo.Graph) - m.Get("/commit/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) - m.Get("/commit/{sha:([a-f0-9]{7,40})$}/load-branches-and-tags", repo.LoadBranchesAndTags) - m.Get("/cherry-pick/{sha:([a-f0-9]{7,40})$}", repo.SetEditorconfigIfExists, repo.CherryPick) + m.Get("/commit/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) + m.Get("/commit/{sha:([a-f0-9]{7,64})$}/load-branches-and-tags", repo.LoadBranchesAndTags) + m.Get("/cherry-pick/{sha:([a-f0-9]{7,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick) }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) m.Get("/rss/branch/*", context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed) @@ -1475,7 +1541,7 @@ func registerRoutes(m *web.Route) { m.Group("", func() { m.Get("/forks", repo.Forks) }, context.RepoRef(), reqRepoCodeReader) - m.Get("/commit/{sha:([a-f0-9]{7,40})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) }, ignSignIn, context.RepoAssignment, context.UnitTypes()) m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit) @@ -1509,19 +1575,7 @@ func registerRoutes(m *web.Route) { }) }, ignSignInAndCsrf, lfsServerEnabled) - m.Group("", func() { - m.PostOptions("/git-upload-pack", repo.ServiceUploadPack) - m.PostOptions("/git-receive-pack", repo.ServiceReceivePack) - m.GetOptions("/info/refs", repo.GetInfoRefs) - m.GetOptions("/HEAD", repo.GetTextFile("HEAD")) - m.GetOptions("/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) - m.GetOptions("/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) - m.GetOptions("/objects/info/packs", repo.GetInfoPacks) - m.GetOptions("/objects/info/{file:[^/]*}", repo.GetTextFile("")) - m.GetOptions("/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38}}", repo.GetLooseObject) - m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.pack", repo.GetPackFile) - m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.idx", repo.GetIdxFile) - }, ignSignInAndCsrf, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context_service.UserAssignmentWeb()) + gitHTTPRouters(m) }) }) // ***** END: Repository ***** diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index faa35b8d2f5..a87c426b3ba 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -10,9 +10,9 @@ import ( "strings" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) // https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4 diff --git a/services/actions/auth.go b/services/actions/auth.go new file mode 100644 index 00000000000..8e934d89a84 --- /dev/null +++ b/services/actions/auth.go @@ -0,0 +1,102 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "fmt" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/golang-jwt/jwt/v5" +) + +type actionsClaims struct { + jwt.RegisteredClaims + Scp string `json:"scp"` + TaskID int64 + RunID int64 + JobID int64 + Ac string `json:"ac"` +} + +type actionsCacheScope struct { + Scope string + Permission actionsCachePermission +} + +type actionsCachePermission int + +const ( + actionsCachePermissionRead = 1 << iota + actionsCachePermissionWrite +) + +func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) { + now := time.Now() + + ac, err := json.Marshal(&[]actionsCacheScope{ + { + Scope: "", + Permission: actionsCachePermissionWrite, + }, + }) + if err != nil { + return "", err + } + + claims := actionsClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + NotBefore: jwt.NewNumericDate(now), + }, + Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID), + Ac: string(ac), + TaskID: taskID, + RunID: runID, + JobID: jobID, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret()) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func ParseAuthorizationToken(req *http.Request) (int64, error) { + h := req.Header.Get("Authorization") + if h == "" { + return 0, nil + } + + parts := strings.SplitN(h, " ", 2) + if len(parts) != 2 { + log.Error("split token failed: %s", h) + return 0, fmt.Errorf("split token failed") + } + + token, err := jwt.ParseWithClaims(parts[1], &actionsClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return setting.GetGeneralTokenSigningSecret(), nil + }) + if err != nil { + return 0, err + } + + c, ok := token.Claims.(*actionsClaims) + if !token.Valid || !ok { + return 0, fmt.Errorf("invalid token claim") + } + + return c.TaskID, nil +} diff --git a/services/actions/auth_test.go b/services/actions/auth_test.go new file mode 100644 index 00000000000..f73ae8ae4c3 --- /dev/null +++ b/services/actions/auth_test.go @@ -0,0 +1,64 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func TestCreateAuthorizationToken(t *testing.T) { + var taskID int64 = 23 + token, err := CreateAuthorizationToken(taskID, 1, 2) + assert.Nil(t, err) + assert.NotEqual(t, "", token) + claims := jwt.MapClaims{} + _, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + return setting.GetGeneralTokenSigningSecret(), nil + }) + assert.Nil(t, err) + scp, ok := claims["scp"] + assert.True(t, ok, "Has scp claim in jwt token") + assert.Contains(t, scp, "Actions.Results:1:2") + taskIDClaim, ok := claims["TaskID"] + assert.True(t, ok, "Has TaskID claim in jwt token") + assert.Equal(t, float64(taskID), taskIDClaim, "Supplied taskid must match stored one") + acClaim, ok := claims["ac"] + assert.True(t, ok, "Has ac claim in jwt token") + ac, ok := acClaim.(string) + assert.True(t, ok, "ac claim is a string for buildx gha cache") + scopes := []actionsCacheScope{} + err = json.Unmarshal([]byte(ac), &scopes) + assert.NoError(t, err, "ac claim is a json list for buildx gha cache") + assert.GreaterOrEqual(t, len(scopes), 1, "Expected at least one action cache scope for buildx gha cache") +} + +func TestParseAuthorizationToken(t *testing.T) { + var taskID int64 = 23 + token, err := CreateAuthorizationToken(taskID, 1, 2) + assert.Nil(t, err) + assert.NotEqual(t, "", token) + headers := http.Header{} + headers.Set("Authorization", "Bearer "+token) + rTaskID, err := ParseAuthorizationToken(&http.Request{ + Header: headers, + }) + assert.Nil(t, err) + assert.Equal(t, taskID, rTaskID) +} + +func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) { + headers := http.Header{} + rTaskID, err := ParseAuthorizationToken(&http.Request{ + Header: headers, + }) + assert.Nil(t, err) + assert.Equal(t, int64(0), rTaskID) +} diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index 785eeb5838e..5376c2624c4 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -20,23 +20,59 @@ func Cleanup(taskCtx context.Context, olderThan time.Duration) error { return CleanupArtifacts(taskCtx) } -// CleanupArtifacts removes expired artifacts and set records expired status +// CleanupArtifacts removes expired add need-deleted artifacts and set records expired status func CleanupArtifacts(taskCtx context.Context) error { + if err := cleanExpiredArtifacts(taskCtx); err != nil { + return err + } + return cleanNeedDeleteArtifacts(taskCtx) +} + +func cleanExpiredArtifacts(taskCtx context.Context) error { artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx) if err != nil { return err } log.Info("Found %d expired artifacts", len(artifacts)) for _, artifact := range artifacts { - if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil { - log.Error("Cannot delete artifact %d: %v", artifact.ID, err) - continue - } if err := actions.SetArtifactExpired(taskCtx, artifact.ID); err != nil { log.Error("Cannot set artifact %d expired: %v", artifact.ID, err) continue } + if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil { + log.Error("Cannot delete artifact %d: %v", artifact.ID, err) + continue + } log.Info("Artifact %d set expired", artifact.ID) } return nil } + +// deleteArtifactBatchSize is the batch size of deleting artifacts +const deleteArtifactBatchSize = 100 + +func cleanNeedDeleteArtifacts(taskCtx context.Context) error { + for { + artifacts, err := actions.ListPendingDeleteArtifacts(taskCtx, deleteArtifactBatchSize) + if err != nil { + return err + } + log.Info("Found %d artifacts pending deletion", len(artifacts)) + for _, artifact := range artifacts { + if err := actions.SetArtifactDeleted(taskCtx, artifact.ID); err != nil { + log.Error("Cannot set artifact %d deleted: %v", artifact.ID, err) + continue + } + if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil { + log.Error("Cannot delete artifact %d: %v", artifact.ID, err) + continue + } + log.Info("Artifact %d set deleted", artifact.ID) + } + if len(artifacts) < deleteArtifactBatchSize { + log.Debug("No more artifacts pending deletion") + break + } + } + return nil +} diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index d2893e4f23e..67373782d5c 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -12,20 +12,15 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" ) -const ( - zombieTaskTimeout = 10 * time.Minute - endlessTaskTimeout = 3 * time.Hour - abandonedJobTimeout = 24 * time.Hour -) - // StopZombieTasks stops the task which have running status, but haven't been updated for a long time func StopZombieTasks(ctx context.Context) error { return stopTasks(ctx, actions_model.FindTaskOptions{ Status: actions_model.StatusRunning, - UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-zombieTaskTimeout).Unix()), + UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.ZombieTaskTimeout).Unix()), }) } @@ -33,12 +28,12 @@ func StopZombieTasks(ctx context.Context) error { func StopEndlessTasks(ctx context.Context) error { return stopTasks(ctx, actions_model.FindTaskOptions{ Status: actions_model.StatusRunning, - StartedBefore: timeutil.TimeStamp(time.Now().Add(-endlessTaskTimeout).Unix()), + StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()), }) } func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { - tasks, err := actions_model.FindTasks(ctx, opts) + tasks, err := db.Find[actions_model.ActionTask](ctx, opts) if err != nil { return fmt.Errorf("find tasks: %w", err) } @@ -79,9 +74,9 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { // CancelAbandonedJobs cancels the jobs which have waiting status, but haven't been picked by a runner for a long time func CancelAbandonedJobs(ctx context.Context) error { - jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{ + jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{ Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}, - UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-abandonedJobTimeout).Unix()), + UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.AbandonedJobTimeout).Unix()), }) if err != nil { log.Warn("find abandoned tasks: %v", err) diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 08a7dde67c8..42365539271 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" user_model "code.gitea.io/gitea/models/user" + git "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -63,6 +64,9 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er return fmt.Errorf("head of pull request is missing in event payload") } sha = payload.PullRequest.Head.Sha + case webhook_module.HookEventRelease: + event = string(run.Event) + sha = run.CommitSHA default: return nil } @@ -75,7 +79,7 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er } ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event) state := toCommitStatus(job.Status) - if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{ListAll: true}); err == nil { + if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil { for _, v := range statuses { if v.Context == ctxname { if v.State == state { @@ -114,9 +118,13 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er } creator := user_model.NewActionsUser() + commitID, err := git.NewIDFromString(sha) + if err != nil { + return fmt.Errorf("HashTypeInterfaceFromHashString: %w", err) + } if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{ Repo: repo, - SHA: sha, + SHA: commitID, Creator: creator, CommitStatus: &git_model.CommitStatus{ SHA: sha, diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index f7ec615364a..d2bbbd9a7ca 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -7,12 +7,14 @@ import ( "context" "errors" "fmt" + "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/queue" + "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) @@ -44,7 +46,7 @@ func jobEmitterQueueHandler(items ...*jobUpdate) []*jobUpdate { } func checkJobsOfRun(ctx context.Context, runID int64) error { - jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: runID}) + jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID}) if err != nil { return err } @@ -76,12 +78,15 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { type jobStatusResolver struct { statuses map[int64]actions_model.Status needs map[int64][]int64 + jobMap map[int64]*actions_model.ActionRunJob } func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver { idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) + jobMap := make(map[int64]*actions_model.ActionRunJob) for _, job := range jobs { idToJobs[job.JobID] = append(idToJobs[job.JobID], job) + jobMap[job.ID] = job } statuses := make(map[int64]actions_model.Status, len(jobs)) @@ -97,6 +102,7 @@ func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver { return &jobStatusResolver{ statuses: statuses, needs: needs, + jobMap: jobMap, } } @@ -135,7 +141,20 @@ func (r *jobStatusResolver) resolve() map[int64]actions_model.Status { if allSucceed { ret[id] = actions_model.StatusWaiting } else { - ret[id] = actions_model.StatusSkipped + // If a job's "if" condition is "always()", the job should always run even if some of its dependencies did not succeed. + // See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idneeds + always := false + if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload); len(wfJobs) == 1 { + _, wfJob := wfJobs[0].Job() + expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(wfJob.If.Value, "${{"), "}}")) + always = expr == "always()" + } + + if always { + ret[id] = actions_model.StatusWaiting + } else { + ret[id] = actions_model.StatusSkipped + } } } } diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index e81aa61d807..038df7d4f86 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -70,6 +70,62 @@ func Test_jobStatusResolver_Resolve(t *testing.T) { }, want: map[int64]actions_model.Status{}, }, + { + name: "with ${{ always() }} condition", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}}, + {ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte( + ` +name: test +on: push +jobs: + job2: + runs-on: ubuntu-latest + needs: job1 + if: ${{ always() }} + steps: + - run: echo "always run" +`)}, + }, + want: map[int64]actions_model.Status{2: actions_model.StatusWaiting}, + }, + { + name: "with always() condition", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}}, + {ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte( + ` +name: test +on: push +jobs: + job2: + runs-on: ubuntu-latest + needs: job1 + if: always() + steps: + - run: echo "always run" +`)}, + }, + want: map[int64]actions_model.Status{2: actions_model.StatusWaiting}, + }, + { + name: "without always() condition", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}}, + {ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte( + ` +name: test +on: push +jobs: + job2: + runs-on: ubuntu-latest + needs: job1 + steps: + - run: echo "not always run" +`)}, + }, + want: map[int64]actions_model.Status{2: actions_model.StatusSkipped}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 6dc44143b6f..6551da39e72 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -6,7 +6,6 @@ package actions import ( "context" - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" packages_model "code.gitea.io/gitea/models/packages" perm_model "code.gitea.io/gitea/models/perm" @@ -50,12 +49,53 @@ func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issu newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{ Action: api.HookIssueOpened, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, issue.Poster, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, issue.Poster, nil), }).Notify(withMethod(ctx, "NewIssue")) } +// IssueChangeContent notifies change content of issue +func (n *actionsNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { + ctx = withMethod(ctx, "IssueChangeContent") + + var err error + if err = issue.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + return + } + + permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) + if issue.IsPull { + if err = issue.LoadPullRequest(ctx); err != nil { + log.Error("loadPullRequest: %v", err) + return + } + newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequest). + WithDoer(doer). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueEdited, + Index: issue.Index, + PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), + Repository: convert.ToRepo(ctx, issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}), + Sender: convert.ToUser(ctx, doer, nil), + }). + WithPullRequest(issue.PullRequest). + Notify(ctx) + return + } + newNotifyInputFromIssue(issue, webhook_module.HookEventIssues). + WithDoer(doer). + WithPayload(&api.IssuePayload{ + Action: api.HookIssueEdited, + Index: issue.Index, + Issue: convert.ToAPIIssue(ctx, doer, issue), + Repository: convert.ToRepo(ctx, issue.Repo, permission), + Sender: convert.ToUser(ctx, doer, nil), + }). + Notify(ctx) +} + // IssueChangeStatus notifies close or reopen issue to notifiers func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { ctx = withMethod(ctx, "IssueChangeStatus") @@ -68,7 +108,7 @@ func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_mode // Merge pull request calls issue.changeStatus so we need to handle separately. apiPullRequest := &api.PullRequestPayload{ Index: issue.Index, - PullRequest: convert.ToAPIPullRequest(db.DefaultContext, issue.PullRequest, nil), + PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), CommitID: commitID, @@ -87,7 +127,7 @@ func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_mode } apiIssue := &api.IssuePayload{ Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), } @@ -102,11 +142,58 @@ func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_mode Notify(ctx) } +// IssueChangeAssignee notifies assigned or unassigned to notifiers +func (n *actionsNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { + ctx = withMethod(ctx, "IssueChangeAssignee") + + var action api.HookIssueAction + if removed { + action = api.HookIssueUnassigned + } else { + action = api.HookIssueAssigned + } + + hookEvent := webhook_module.HookEventIssueAssign + if issue.IsPull { + hookEvent = webhook_module.HookEventPullRequestAssign + } + + notifyIssueChange(ctx, doer, issue, hookEvent, action) +} + +// IssueChangeMilestone notifies assignee to notifiers +func (n *actionsNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { + ctx = withMethod(ctx, "IssueChangeMilestone") + + var action api.HookIssueAction + if issue.MilestoneID > 0 { + action = api.HookIssueMilestoned + } else { + action = api.HookIssueDemilestoned + } + + hookEvent := webhook_module.HookEventIssueMilestone + if issue.IsPull { + hookEvent = webhook_module.HookEventPullRequestMilestone + } + + notifyIssueChange(ctx, doer, issue, hookEvent, action) +} + func (n *actionsNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, _, _ []*issues_model.Label, ) { ctx = withMethod(ctx, "IssueChangeLabels") + hookEvent := webhook_module.HookEventIssueLabel + if issue.IsPull { + hookEvent = webhook_module.HookEventPullRequestLabel + } + + notifyIssueChange(ctx, doer, issue, hookEvent, api.HookIssueLabelUpdated) +} + +func notifyIssueChange(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, event webhook_module.HookEventType, action api.HookIssueAction) { var err error if err = issue.LoadRepo(ctx); err != nil { log.Error("LoadRepo: %v", err) @@ -118,20 +205,15 @@ func (n *actionsNotifier) IssueChangeLabels(ctx context.Context, doer *user_mode return } - permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) if issue.IsPull { if err = issue.LoadPullRequest(ctx); err != nil { log.Error("loadPullRequest: %v", err) return } - if err = issue.PullRequest.LoadIssue(ctx); err != nil { - log.Error("LoadIssue: %v", err) - return - } - newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestLabel). + newNotifyInputFromIssue(issue, event). WithDoer(doer). WithPayload(&api.PullRequestPayload{ - Action: api.HookIssueLabelUpdated, + Action: action, Index: issue.Index, PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), Repository: convert.ToRepo(ctx, issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}), @@ -141,12 +223,13 @@ func (n *actionsNotifier) IssueChangeLabels(ctx context.Context, doer *user_mode Notify(ctx) return } - newNotifyInputFromIssue(issue, webhook_module.HookEventIssueLabel). + permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) + newNotifyInputFromIssue(issue, event). WithDoer(doer). WithPayload(&api.IssuePayload{ - Action: api.HookIssueLabelUpdated, + Action: action, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }). @@ -159,37 +242,88 @@ func (n *actionsNotifier) CreateIssueComment(ctx context.Context, doer *user_mod ) { ctx = withMethod(ctx, "CreateIssueComment") - permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer) - if issue.IsPull { - if err := issue.LoadPullRequest(ctx); err != nil { + notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventPullRequestComment, api.HookIssueCommentCreated) + return + } + notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventIssueComment, api.HookIssueCommentCreated) +} + +func (n *actionsNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { + ctx = withMethod(ctx, "UpdateComment") + + if err := c.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + + if c.Issue.IsPull { + notifyIssueCommentChange(ctx, doer, c, oldContent, webhook_module.HookEventPullRequestComment, api.HookIssueCommentEdited) + return + } + notifyIssueCommentChange(ctx, doer, c, oldContent, webhook_module.HookEventIssueComment, api.HookIssueCommentEdited) +} + +func (n *actionsNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { + ctx = withMethod(ctx, "DeleteComment") + + if err := comment.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + + if comment.Issue.IsPull { + notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventPullRequestComment, api.HookIssueCommentDeleted) + return + } + notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventIssueComment, api.HookIssueCommentDeleted) +} + +func notifyIssueCommentChange(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, oldContent string, event webhook_module.HookEventType, action api.HookIssueCommentAction) { + if err := comment.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + if err := comment.Issue.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + permission, _ := access_model.GetUserRepoPermission(ctx, comment.Issue.Repo, doer) + + payload := &api.IssueCommentPayload{ + Action: action, + Issue: convert.ToAPIIssue(ctx, doer, comment.Issue), + Comment: convert.ToAPIComment(ctx, comment.Issue.Repo, comment), + Repository: convert.ToRepo(ctx, comment.Issue.Repo, permission), + Sender: convert.ToUser(ctx, doer, nil), + IsPull: comment.Issue.IsPull, + } + + if action == api.HookIssueCommentEdited { + payload.Changes = &api.ChangesPayload{ + Body: &api.ChangesFromPayload{ + From: oldContent, + }, + } + } + + if comment.Issue.IsPull { + if err := comment.Issue.LoadPullRequest(ctx); err != nil { log.Error("LoadPullRequest: %v", err) return } - newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestComment). + newNotifyInputFromIssue(comment.Issue, event). WithDoer(doer). - WithPayload(&api.IssueCommentPayload{ - Action: api.HookIssueCommentCreated, - Issue: convert.ToAPIIssue(ctx, issue), - Comment: convert.ToAPIComment(ctx, repo, comment), - Repository: convert.ToRepo(ctx, repo, permission), - Sender: convert.ToUser(ctx, doer, nil), - IsPull: true, - }). - WithPullRequest(issue.PullRequest). + WithPayload(payload). + WithPullRequest(comment.Issue.PullRequest). Notify(ctx) return } - newNotifyInputFromIssue(issue, webhook_module.HookEventIssueComment). + + newNotifyInputFromIssue(comment.Issue, event). WithDoer(doer). - WithPayload(&api.IssueCommentPayload{ - Action: api.HookIssueCommentCreated, - Issue: convert.ToAPIIssue(ctx, issue), - Comment: convert.ToAPIComment(ctx, repo, comment), - Repository: convert.ToRepo(ctx, repo, permission), - Sender: convert.ToUser(ctx, doer, nil), - IsPull: false, - }). + WithPayload(payload). Notify(ctx) } @@ -296,7 +430,7 @@ func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_mode WithPayload(&api.PullRequestPayload{ Action: api.HookIssueReviewed, Index: review.Issue.Index, - PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), + PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), Repository: convert.ToRepo(ctx, review.Issue.Repo, permission), Sender: convert.ToUser(ctx, review.Reviewer, nil), Review: &api.ReviewPayload{ @@ -306,6 +440,39 @@ func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_mode }).Notify(ctx) } +func (n *actionsNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { + if !issue.IsPull { + log.Warn("PullRequestReviewRequest: issue is not a pull request: %v", issue.ID) + return + } + + ctx = withMethod(ctx, "PullRequestReviewRequest") + + permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err := issue.LoadPullRequest(ctx); err != nil { + log.Error("LoadPullRequest failed: %v", err) + return + } + var action api.HookIssueAction + if isRequest { + action = api.HookIssueReviewRequested + } else { + action = api.HookIssueReviewRequestRemoved + } + newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestReviewRequest). + WithDoer(doer). + WithPayload(&api.PullRequestPayload{ + Action: action, + Index: issue.Index, + PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), + RequestedReviewer: convert.ToUser(ctx, reviewer, nil), + Repository: convert.ToRepo(ctx, issue.Repo, permission), + Sender: convert.ToUser(ctx, doer, nil), + }). + WithPullRequest(issue.PullRequest). + Notify(ctx) +} + func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { ctx = withMethod(ctx, "MergePullRequest") @@ -320,7 +487,7 @@ func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.U return } - if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + if err := pr.Issue.LoadRepo(ctx); err != nil { log.Error("pr.Issue.LoadRepo: %v", err) return } @@ -334,7 +501,7 @@ func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.U // Merge pull request calls issue.changeStatus so we need to handle separately. apiPullRequest := &api.PullRequestPayload{ Index: pr.Issue.Index, - PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), + PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), Repository: convert.ToRepo(ctx, pr.Issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), Action: api.HookIssueClosed, @@ -348,6 +515,12 @@ func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.U } func (n *actionsNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + commitID, _ := git.NewIDFromString(opts.NewCommitID) + if commitID.IsZero() { + log.Trace("new commitID is empty") + return + } + ctx = withMethod(ctx, "PushCommits") apiPusher := convert.ToUser(ctx, pusher, nil) @@ -380,9 +553,9 @@ func (n *actionsNotifier) CreateRef(ctx context.Context, pusher *user_model.User apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}) newNotifyInput(repo, pusher, webhook_module.HookEventCreate). - WithRef(refFullName.ShortName()). // FIXME: should we use a full ref name + WithRef(refFullName.String()). WithPayload(&api.CreatePayload{ - Ref: refFullName.ShortName(), + Ref: refFullName.String(), Sha: refID, RefType: refFullName.RefType(), Repo: apiRepo, @@ -398,9 +571,8 @@ func (n *actionsNotifier) DeleteRef(ctx context.Context, pusher *user_model.User apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}) newNotifyInput(repo, pusher, webhook_module.HookEventDelete). - WithRef(refFullName.ShortName()). // FIXME: should we use a full ref name WithPayload(&api.DeletePayload{ - Ref: refFullName.ShortName(), + Ref: refFullName.String(), RefType: refFullName.RefType(), PusherType: api.PusherTypeUser, Repo: apiRepo, @@ -413,7 +585,7 @@ func (n *actionsNotifier) SyncPushCommits(ctx context.Context, pusher *user_mode ctx = withMethod(ctx, "SyncPushCommits") apiPusher := convert.ToUser(ctx, pusher, nil) - apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(db.DefaultContext, repo.RepoPath(), repo.HTMLURL()) + apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) if err != nil { log.Error("commits.ToAPIPayloadCommits failed: %v", err) return @@ -457,6 +629,10 @@ func (n *actionsNotifier) UpdateRelease(ctx context.Context, doer *user_model.Us } func (n *actionsNotifier) DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + if rel.IsTag { + // has sent same action in `PushCommits`, so skip it. + return + } ctx = withMethod(ctx, "DeleteRelease") notifyRelease(ctx, doer, rel, api.HookReleaseDeleted) } @@ -484,7 +660,7 @@ func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *use return } - if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + if err := pr.Issue.LoadRepo(ctx); err != nil { log.Error("pr.Issue.LoadRepo: %v", err) return } @@ -509,7 +685,7 @@ func (n *actionsNotifier) PullRequestChangeTargetBranch(ctx context.Context, doe return } - if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + if err := pr.Issue.LoadRepo(ctx); err != nil { log.Error("pr.Issue.LoadRepo: %v", err) return } @@ -566,3 +742,15 @@ func (n *actionsNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.U Page: page, }).Notify(ctx) } + +// MigrateRepository is used to detect workflows after a repository has been migrated +func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + ctx = withMethod(ctx, "MigrateRepository") + + newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{ + Action: api.HookRepoCreated, + Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}), + Organization: convert.ToUser(ctx, u, nil), + Sender: convert.ToUser(ctx, doer, nil), + }).Notify(ctx) +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index ff00e48c644..8c98f56af53 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -7,9 +7,11 @@ import ( "bytes" "context" "fmt" + "slices" "strings" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" packages_model "code.gitea.io/gitea/models/packages" access_model "code.gitea.io/gitea/models/perm/access" @@ -18,8 +20,10 @@ import ( user_model "code.gitea.io/gitea/models/user" actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" @@ -113,7 +117,13 @@ func notify(ctx context.Context, input *notifyInput) error { log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) return nil } + if input.Repo.IsEmpty || input.Repo.IsArchived { + return nil + } if unit_model.TypeActions.UnitGlobalDisabled() { + if err := actions_model.CleanRepoScheduleTasks(ctx, input.Repo); err != nil { + log.Error("CleanRepoScheduleTasks: %v", err) + } return nil } if err := input.Repo.LoadUnits(ctx); err != nil { @@ -122,19 +132,22 @@ func notify(ctx context.Context, input *notifyInput) error { return nil } - gitRepo, err := git.OpenRepository(context.Background(), input.Repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(context.Background(), input.Repo) if err != nil { return fmt.Errorf("git.OpenRepository: %w", err) } defer gitRepo.Close() ref := input.Ref - if input.Event == webhook_module.HookEventDelete { - // The event is deleting a reference, so it will fail to get the commit for a deleted reference. - // Set ref to empty string to fall back to the default branch. - ref = "" + if ref != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) { + if ref != "" { + log.Warn("Event %q should only trigger workflows on the default branch, but its ref is %q. Will fall back to the default branch", + input.Event, ref) + } + ref = input.Repo.DefaultBranch } if ref == "" { + log.Warn("Ref of event %q is empty, will fall back to the default branch", input.Event) ref = input.Repo.DefaultBranch } @@ -144,25 +157,38 @@ func notify(ctx context.Context, input *notifyInput) error { return fmt.Errorf("gitRepo.GetCommit: %w", err) } + if skipWorkflows(input, commit) { + return nil + } + var detectedWorkflows []*actions_module.DetectedWorkflow actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig() - workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit, input.Event, input.Payload) + shouldDetectSchedules := input.Event == webhook_module.HookEventPush && git.RefName(input.Ref).BranchName() == input.Repo.DefaultBranch + workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit, + input.Event, + input.Payload, + shouldDetectSchedules, + ) if err != nil { return fmt.Errorf("DetectWorkflows: %w", err) } - if len(workflows) == 0 { - log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID) - } else { - for _, wf := range workflows { - if actionsConfig.IsWorkflowDisabled(wf.EntryName) { - log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName) - continue - } + log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules", + input.Repo.RepoPath(), + commit.ID, + input.Event, + len(workflows), + len(schedules), + ) - if wf.TriggerEvent != actions_module.GithubEventPullRequestTarget { - detectedWorkflows = append(detectedWorkflows, wf) - } + for _, wf := range workflows { + if actionsConfig.IsWorkflowDisabled(wf.EntryName) { + log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName) + continue + } + + if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget { + detectedWorkflows = append(detectedWorkflows, wf) } } @@ -173,7 +199,7 @@ func notify(ctx context.Context, input *notifyInput) error { if err != nil { return fmt.Errorf("gitRepo.GetCommit: %w", err) } - baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload) + baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false) if err != nil { return fmt.Errorf("DetectWorkflows: %w", err) } @@ -181,20 +207,45 @@ func notify(ctx context.Context, input *notifyInput) error { log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID) } else { for _, wf := range baseWorkflows { - if wf.TriggerEvent == actions_module.GithubEventPullRequestTarget { + if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget { detectedWorkflows = append(detectedWorkflows, wf) } } } } - if err := handleSchedules(ctx, schedules, commit, input); err != nil { - return err + if shouldDetectSchedules { + if err := handleSchedules(ctx, schedules, commit, input, ref); err != nil { + return err + } } return handleWorkflows(ctx, detectedWorkflows, commit, input, ref) } +func skipWorkflows(input *notifyInput, commit *git.Commit) bool { + // skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync) + // https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs + skipWorkflowEvents := []webhook_module.HookEventType{ + webhook_module.HookEventPush, + webhook_module.HookEventPullRequest, + webhook_module.HookEventPullRequestSync, + } + if slices.Contains(skipWorkflowEvents, input.Event) { + for _, s := range setting.Actions.SkipWorkflowStrings { + if input.PullRequest != nil && strings.Contains(input.PullRequest.Issue.Title, s) { + log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RepoPath(), input.PullRequest.Issue.ID, s) + return true + } + if strings.Contains(commit.CommitMessage, s) { + log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RepoPath(), commit.ID, s) + return true + } + } + } + return false +} + func handleWorkflows( ctx context.Context, detectedWorkflows []*actions_module.DetectedWorkflow, @@ -239,7 +290,7 @@ func handleWorkflows( IsForkPullRequest: isForkPullRequest, Event: input.Event, EventPayload: string(p), - TriggerEvent: dwf.TriggerEvent, + TriggerEvent: dwf.TriggerEvent.Name, Status: actions_model.StatusWaiting, } if need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer); err != nil { @@ -249,22 +300,34 @@ func handleWorkflows( run.NeedApproval = need } - jobs, err := jobparser.Parse(dwf.Content) + if err := run.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + continue + } + + vars, err := actions_model.GetVariablesOfRun(ctx, run) + if err != nil { + log.Error("GetVariablesOfRun: %v", err) + continue + } + + jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars)) if err != nil { log.Error("jobparser.Parse: %v", err) continue } - // cancel running jobs if the event is push - if run.Event == webhook_module.HookEventPush { - // cancel running jobs of the same workflow - if err := actions_model.CancelRunningJobs( + // cancel running jobs if the event is push or pull_request_sync + if run.Event == webhook_module.HookEventPush || + run.Event == webhook_module.HookEventPullRequestSync { + if err := actions_model.CancelPreviousJobs( ctx, run.RepoID, run.Ref, run.WorkflowID, + run.Event, ); err != nil { - log.Error("CancelRunningJobs: %v", err) + log.Error("CancelPreviousJobs: %v", err) } } @@ -273,7 +336,7 @@ func handleWorkflows( continue } - alljobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) if err != nil { log.Error("FindRunJobs: %v", err) continue @@ -352,7 +415,7 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep } // don't need approval if the user has been approved before - if count, err := actions_model.CountRuns(ctx, actions_model.FindRunOptions{ + if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{ RepoID: repo.ID, TriggerUserID: user.ID, Approved: true, @@ -373,12 +436,8 @@ func handleSchedules( detectedWorkflows []*actions_module.DetectedWorkflow, commit *git.Commit, input *notifyInput, + ref string, ) error { - if len(detectedWorkflows) == 0 { - log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RepoPath(), commit.ID) - return nil - } - branch, err := commit.GetBranchName() if err != nil { return err @@ -388,16 +447,18 @@ func handleSchedules( return nil } - rows, _, err := actions_model.FindSchedules(ctx, actions_model.FindScheduleOptions{RepoID: input.Repo.ID}) - if err != nil { - log.Error("FindCrons: %v", err) + if count, err := db.Count[actions_model.ActionSchedule](ctx, actions_model.FindScheduleOptions{RepoID: input.Repo.ID}); err != nil { + log.Error("CountSchedules: %v", err) return err + } else if count > 0 { + if err := actions_model.CleanRepoScheduleTasks(ctx, input.Repo); err != nil { + log.Error("CleanRepoScheduleTasks: %v", err) + } } - if len(rows) > 0 { - if err := actions_model.DeleteScheduleTaskByRepo(ctx, input.Repo.ID); err != nil { - log.Error("DeleteCronTaskByRepo: %v", err) - } + if len(detectedWorkflows) == 0 { + log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RepoPath(), commit.ID) + return nil } p, err := json.Marshal(input.Payload) @@ -425,28 +486,51 @@ func handleSchedules( OwnerID: input.Repo.OwnerID, WorkflowID: dwf.EntryName, TriggerUserID: input.Doer.ID, - Ref: input.Ref, + Ref: ref, CommitSHA: commit.ID.String(), Event: input.Event, EventPayload: string(p), Specs: schedules, Content: dwf.Content, } - - // cancel running jobs if the event is push - if run.Event == webhook_module.HookEventPush { - // cancel running jobs of the same workflow - if err := actions_model.CancelRunningJobs( - ctx, - run.RepoID, - run.Ref, - run.WorkflowID, - ); err != nil { - log.Error("CancelRunningJobs: %v", err) - } - } crons = append(crons, run) } return actions_model.CreateScheduleTask(ctx, crons) } + +// DetectAndHandleSchedules detects the schedule workflows on the default branch and create schedule tasks +func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository) error { + if repo.IsEmpty || repo.IsArchived { + return nil + } + + gitRepo, err := gitrepo.OpenRepository(context.Background(), repo) + if err != nil { + return fmt.Errorf("git.OpenRepository: %w", err) + } + defer gitRepo.Close() + + // Only detect schedule workflows on the default branch + commit, err := gitRepo.GetCommit(repo.DefaultBranch) + if err != nil { + return fmt.Errorf("gitRepo.GetCommit: %w", err) + } + scheduleWorkflows, err := actions_module.DetectScheduledWorkflows(gitRepo, commit) + if err != nil { + return fmt.Errorf("detect schedule workflows: %w", err) + } + if len(scheduleWorkflows) == 0 { + return nil + } + + // We need a notifyInput to call handleSchedules + // Here we use the commit author as the Doer of the notifyInput + commitUser, err := user_model.GetUserByEmail(ctx, commit.Author.Email) + if err != nil { + return fmt.Errorf("get user by email: %w", err) + } + notifyInput := newNotifyInput(repo, commitUser, webhook_module.HookEventSchedule) + + return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, repo.DefaultBranch) +} diff --git a/services/actions/rerun.go b/services/actions/rerun.go new file mode 100644 index 00000000000..60f66509058 --- /dev/null +++ b/services/actions/rerun.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/container" +) + +// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun +func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { + rerunJobs := []*actions_model.ActionRunJob{job} + rerunJobsIDSet := make(container.Set[string]) + rerunJobsIDSet.Add(job.JobID) + + for { + found := false + for _, j := range allJobs { + if rerunJobsIDSet.Contains(j.JobID) { + continue + } + for _, need := range j.Needs { + if rerunJobsIDSet.Contains(need) { + found = true + rerunJobs = append(rerunJobs, j) + rerunJobsIDSet.Add(j.JobID) + break + } + } + } + if !found { + break + } + } + + return rerunJobs +} diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go new file mode 100644 index 00000000000..a98de7b7882 --- /dev/null +++ b/services/actions/rerun_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + + "github.com/stretchr/testify/assert" +) + +func TestGetAllRerunJobs(t *testing.T) { + job1 := &actions_model.ActionRunJob{JobID: "job1"} + job2 := &actions_model.ActionRunJob{JobID: "job2", Needs: []string{"job1"}} + job3 := &actions_model.ActionRunJob{JobID: "job3", Needs: []string{"job2"}} + job4 := &actions_model.ActionRunJob{JobID: "job4", Needs: []string{"job2", "job3"}} + + jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} + + testCases := []struct { + job *actions_model.ActionRunJob + rerunJobs []*actions_model.ActionRunJob + }{ + { + job1, + []*actions_model.ActionRunJob{job1, job2, job3, job4}, + }, + { + job2, + []*actions_model.ActionRunJob{job2, job3, job4}, + }, + { + job3, + []*actions_model.ActionRunJob{job3, job4}, + }, + { + job4, + []*actions_model.ActionRunJob{job4}, + }, + } + + for _, tc := range testCases { + rerunJobs := GetAllRerunJobs(tc.job, jobs) + assert.ElementsMatch(t, tc.rerunJobs, rerunJobs) + } +} diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 40b606c7774..e4e56e51229 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -10,6 +10,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" @@ -45,7 +46,7 @@ func startTasks(ctx context.Context) error { return fmt.Errorf("find specs: %w", err) } - if err := specs.LoadRepos(); err != nil { + if err := specs.LoadRepos(ctx); err != nil { return fmt.Errorf("LoadRepos: %w", err) } @@ -54,18 +55,31 @@ func startTasks(ctx context.Context) error { // cancel running jobs if the event is push if row.Schedule.Event == webhook_module.HookEventPush { // cancel running jobs of the same workflow - if err := actions_model.CancelRunningJobs( + if err := actions_model.CancelPreviousJobs( ctx, row.RepoID, row.Schedule.Ref, row.Schedule.WorkflowID, + webhook_module.HookEventSchedule, ); err != nil { - log.Error("CancelRunningJobs: %v", err) + log.Error("CancelPreviousJobs: %v", err) } } - cfg := row.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() - if cfg.IsWorkflowDisabled(row.Schedule.WorkflowID) { + if row.Repo.IsArchived { + // Skip if the repo is archived + continue + } + + cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions) + if err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + // Skip the actions unit of this repo is disabled. + continue + } + return fmt.Errorf("GetUnit: %w", err) + } + if cfg.ActionsConfig().IsWorkflowDisabled(row.Schedule.WorkflowID) { continue } @@ -113,6 +127,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) CommitSHA: cron.CommitSHA, Event: cron.Event, EventPayload: cron.EventPayload, + TriggerEvent: string(webhook_module.HookEventSchedule), ScheduleID: cron.ID, Status: actions_model.StatusWaiting, } diff --git a/services/actions/variables.go b/services/actions/variables.go new file mode 100644 index 00000000000..8dde9c4af5c --- /dev/null +++ b/services/actions/variables.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "regexp" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + secret_service "code.gitea.io/gitea/services/secrets" +) + +func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data string) (*actions_model.ActionVariable, error) { + if err := secret_service.ValidateName(name); err != nil { + return nil, err + } + + if err := envNameCIRegexMatch(name); err != nil { + return nil, err + } + + v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data)) + if err != nil { + return nil, err + } + + return v, nil +} + +func UpdateVariable(ctx context.Context, variableID int64, name, data string) (bool, error) { + if err := secret_service.ValidateName(name); err != nil { + return false, err + } + + if err := envNameCIRegexMatch(name); err != nil { + return false, err + } + + return actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ + ID: variableID, + Name: strings.ToUpper(name), + Data: util.ReserveLineBreakForTextarea(data), + }) +} + +func DeleteVariableByID(ctx context.Context, variableID int64) error { + return actions_model.DeleteVariable(ctx, variableID) +} + +func DeleteVariableByName(ctx context.Context, ownerID, repoID int64, name string) error { + if err := secret_service.ValidateName(name); err != nil { + return err + } + + if err := envNameCIRegexMatch(name); err != nil { + return err + } + + v, err := GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + RepoID: repoID, + Name: name, + }) + if err != nil { + return err + } + + return actions_model.DeleteVariable(ctx, v.ID) +} + +func GetVariable(ctx context.Context, opts actions_model.FindVariablesOpts) (*actions_model.ActionVariable, error) { + vars, err := actions_model.FindVariables(ctx, opts) + if err != nil { + return nil, err + } + if len(vars) != 1 { + return nil, util.NewNotExistErrorf("variable not found") + } + return vars[0], nil +} + +// some regular expression of `variables` and `secrets` +// reference to: +// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables +// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets +var ( + forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") +) + +func envNameCIRegexMatch(name string) error { + if forbiddenEnvNameCIRx.MatchString(name) { + log.Error("Env Name cannot be ci") + return util.NewInvalidArgumentErrorf("env name cannot be ci") + } + return nil +} diff --git a/services/agit/agit.go b/services/agit/agit.go index a39034b0256..52a70469e0c 100644 --- a/services/agit/agit.go +++ b/services/agit/agit.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" issues_model "code.gitea.io/gitea/models/issues" @@ -15,30 +16,28 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) // ProcReceive handle proc receive work func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) { - // TODO: Add more options? - var ( - topicBranch string - title string - description string - forcePush bool - ) - results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs)) - - ownerName := repo.OwnerName - repoName := repo.Name - - topicBranch = opts.GitPushOptions["topic"] - _, forcePush = opts.GitPushOptions["force-push"] + topicBranch := opts.GitPushOptions["topic"] + forcePush, _ := strconv.ParseBool(opts.GitPushOptions["force-push"]) + title := strings.TrimSpace(opts.GitPushOptions["title"]) + description := strings.TrimSpace(opts.GitPushOptions["description"]) // TODO: Add more options? + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) + userName := strings.ToLower(opts.UserName) + + pusher, err := user_model.GetUserByID(ctx, opts.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get user. Error: %w", err) + } for i := range opts.OldCommitIDs { - if opts.NewCommitIDs[i] == git.EmptySHA { + if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() { results = append(results, private.HookProcReceiveRefResult{ OriginalRef: opts.RefFullNames[i], OldOID: opts.OldCommitIDs[i], @@ -79,9 +78,6 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. continue } - var headBranch string - userName := strings.ToLower(opts.UserName) - if len(curentTopicBranch) == 0 { curentTopicBranch = topicBranch } @@ -89,6 +85,7 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. // because different user maybe want to use same topic, // So it's better to make sure the topic branch name // has user name prefix + var headBranch string if !strings.HasPrefix(curentTopicBranch, userName+"/") { headBranch = userName + "/" + curentTopicBranch } else { @@ -98,26 +95,26 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, repo.ID, headBranch, baseBranchName, issues_model.PullRequestFlowAGit) if err != nil { if !issues_model.IsErrPullRequestNotExist(err) { - return nil, fmt.Errorf("Failed to get unmerged agit flow pull request in repository: %s/%s Error: %w", ownerName, repoName, err) + return nil, fmt.Errorf("failed to get unmerged agit flow pull request in repository: %s Error: %w", repo.FullName(), err) } - // create a new pull request - if len(title) == 0 { - var has bool - title, has = opts.GitPushOptions["title"] - if !has || len(title) == 0 { - commit, err := gitRepo.GetCommit(opts.NewCommitIDs[i]) - if err != nil { - return nil, fmt.Errorf("Failed to get commit %s in repository: %s/%s Error: %w", opts.NewCommitIDs[i], ownerName, repoName, err) - } - title = strings.Split(commit.CommitMessage, "\n")[0] + var commit *git.Commit + if title == "" || description == "" { + commit, err = gitRepo.GetCommit(opts.NewCommitIDs[i]) + if err != nil { + return nil, fmt.Errorf("failed to get commit %s in repository: %s Error: %w", opts.NewCommitIDs[i], repo.FullName(), err) } - description = opts.GitPushOptions["description"] } - pusher, err := user_model.GetUserByID(ctx, opts.UserID) - if err != nil { - return nil, fmt.Errorf("Failed to get user. Error: %w", err) + // create a new pull request + if title == "" { + title = strings.Split(commit.CommitMessage, "\n")[0] + } + if description == "" { + _, description, _ = strings.Cut(commit.CommitMessage, "\n\n") + } + if description == "" { + description = title } prIssue := &issues_model.Issue{ @@ -149,22 +146,26 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID) results = append(results, private.HookProcReceiveRefResult{ - Ref: pr.GetGitRefName(), - OriginalRef: opts.RefFullNames[i], - OldOID: git.EmptySHA, - NewOID: opts.NewCommitIDs[i], + Ref: pr.GetGitRefName(), + OriginalRef: opts.RefFullNames[i], + OldOID: objectFormat.EmptyObjectID().String(), + NewOID: opts.NewCommitIDs[i], + IsCreatePR: true, + URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index), + ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx), + HeadBranch: headBranch, }) continue } // update exist pull request if err := pr.LoadBaseRepo(ctx); err != nil { - return nil, fmt.Errorf("Unable to load base repository for PR[%d] Error: %w", pr.ID, err) + return nil, fmt.Errorf("unable to load base repository for PR[%d] Error: %w", pr.ID, err) } oldCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) if err != nil { - return nil, fmt.Errorf("Unable to get ref commit id in base repository for PR[%d] Error: %w", pr.ID, err) + return nil, fmt.Errorf("unable to get ref commit id in base repository for PR[%d] Error: %w", pr.ID, err) } if oldCommitID == opts.NewCommitIDs[i] { @@ -178,9 +179,11 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. } if !forcePush { - output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1").AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]).RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: os.Environ()}) + output, _, err := git.NewCommand(ctx, "rev-list", "--max-count=1"). + AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]). + RunStdString(&git.RunOpts{Dir: repo.RepoPath(), Env: os.Environ()}) if err != nil { - return nil, fmt.Errorf("Fail to detect force push: %w", err) + return nil, fmt.Errorf("failed to detect force push: %w", err) } else if len(output) > 0 { results = append(results, private.HookProcReceiveRefResult{ OriginalRef: opts.RefFullNames[i], @@ -194,17 +197,13 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. pr.HeadCommitID = opts.NewCommitIDs[i] if err = pull_service.UpdateRef(ctx, pr); err != nil { - return nil, fmt.Errorf("Failed to update pull ref. Error: %w", err) + return nil, fmt.Errorf("failed to update pull ref. Error: %w", err) } pull_service.AddToTaskQueue(ctx, pr) - pusher, err := user_model.GetUserByID(ctx, opts.UserID) - if err != nil { - return nil, fmt.Errorf("Failed to get user. Error: %w", err) - } err = pr.LoadIssue(ctx) if err != nil { - return nil, fmt.Errorf("Failed to load pull issue. Error: %w", err) + return nil, fmt.Errorf("failed to load pull issue. Error: %w", err) } comment, err := pull_service.CreatePushPullComment(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i]) if err == nil && comment != nil { @@ -214,11 +213,14 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. isForcePush := comment != nil && comment.IsForcePush results = append(results, private.HookProcReceiveRefResult{ - OldOID: oldCommitID, - NewOID: opts.NewCommitIDs[i], - Ref: pr.GetGitRefName(), - OriginalRef: opts.RefFullNames[i], - IsForcePush: isForcePush, + OldOID: oldCommitID, + NewOID: opts.NewCommitIDs[i], + Ref: pr.GetGitRefName(), + OriginalRef: opts.RefFullNames[i], + IsForcePush: isForcePush, + IsCreatePR: false, + URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index), + ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx), }) } @@ -237,7 +239,7 @@ func UserNameChanged(ctx context.Context, user *user_model.User, newName string) for _, pull := range pulls { pull.HeadBranch = strings.TrimPrefix(pull.HeadBranch, user.LowerName+"/") pull.HeadBranch = newName + "/" + pull.HeadBranch - if err = pull.UpdateCols("head_branch"); err != nil { + if err = pull.UpdateCols(ctx, "head_branch"); err != nil { return err } } diff --git a/services/asymkey/deploy_key.go b/services/asymkey/deploy_key.go index f5ca54b7238..324688c5340 100644 --- a/services/asymkey/deploy_key.go +++ b/services/asymkey/deploy_key.go @@ -4,26 +4,27 @@ package asymkey import ( + "context" + "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" ) // DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed. -func DeleteDeployKey(doer *user_model.User, id int64) error { - ctx, committer, err := db.TxContext(db.DefaultContext) +func DeleteDeployKey(ctx context.Context, doer *user_model.User, id int64) error { + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err := models.DeleteDeployKey(ctx, doer, id); err != nil { + if err := models.DeleteDeployKey(dbCtx, doer, id); err != nil { return err } if err := committer.Commit(); err != nil { return err } - return asymkey_model.RewriteAllPublicKeys() + return RewriteAllPublicKeys(ctx) } diff --git a/services/asymkey/main_test.go b/services/asymkey/main_test.go index e7a03861b9c..3505b26f699 100644 --- a/services/asymkey/main_test.go +++ b/services/asymkey/main_test.go @@ -4,7 +4,6 @@ package asymkey import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -14,7 +13,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/asymkey/sign.go b/services/asymkey/sign.go index 1598f321653..2f5d76a2933 100644 --- a/services/asymkey/sign.go +++ b/services/asymkey/sign.go @@ -13,8 +13,10 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" @@ -143,7 +145,10 @@ Loop: case always: break Loop case pubkey: - keys, err := asymkey_model.ListGPGKeys(ctx, u.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + OwnerID: u.ID, + IncludeSubKeys: true, + }) if err != nil { return false, "", nil, err } @@ -164,7 +169,8 @@ Loop: } // SignWikiCommit determines if we should sign the commits to this repository wiki -func SignWikiCommit(ctx context.Context, repoWikiPath string, u *user_model.User) (bool, string, *git.Signature, error) { +func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, u *user_model.User) (bool, string, *git.Signature, error) { + repoWikiPath := repo.WikiPath() rules := signingModeFromStrings(setting.Repository.Signing.Wiki) signingKey, sig := SigningKey(ctx, repoWikiPath) if signingKey == "" { @@ -179,7 +185,10 @@ Loop: case always: break Loop case pubkey: - keys, err := asymkey_model.ListGPGKeys(ctx, u.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + OwnerID: u.ID, + IncludeSubKeys: true, + }) if err != nil { return false, "", nil, err } @@ -195,7 +204,7 @@ Loop: return false, "", nil, &ErrWontSign{twofa} } case parentSigned: - gitRepo, err := git.OpenRepository(ctx, repoWikiPath) + gitRepo, err := gitrepo.OpenWikiRepository(ctx, repo) if err != nil { return false, "", nil, err } @@ -232,7 +241,10 @@ Loop: case always: break Loop case pubkey: - keys, err := asymkey_model.ListGPGKeys(ctx, u.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + OwnerID: u.ID, + IncludeSubKeys: true, + }) if err != nil { return false, "", nil, err } @@ -294,7 +306,10 @@ Loop: case always: break Loop case pubkey: - keys, err := asymkey_model.ListGPGKeys(ctx, u.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + OwnerID: u.ID, + IncludeSubKeys: true, + }) if err != nil { return false, "", nil, err } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index 0809458107b..da57059d4b4 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -4,14 +4,16 @@ package asymkey import ( + "context" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" ) // DeletePublicKey deletes SSH key information both in database and authorized_keys file. -func DeletePublicKey(doer *user_model.User, id int64) (err error) { - key, err := asymkey_model.GetPublicKeyByID(id) +func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err error) { + key, err := asymkey_model.GetPublicKeyByID(ctx, id) if err != nil { return err } @@ -25,13 +27,13 @@ func DeletePublicKey(doer *user_model.User, id int64) (err error) { } } - ctx, committer, err := db.TxContext(db.DefaultContext) + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err = asymkey_model.DeletePublicKeys(ctx, id); err != nil { + if _, err = db.DeleteByID[asymkey_model.PublicKey](dbCtx, id); err != nil { return err } @@ -41,8 +43,8 @@ func DeletePublicKey(doer *user_model.User, id int64) (err error) { committer.Close() if key.Type == asymkey_model.KeyTypePrincipal { - return asymkey_model.RewriteAllPrincipalKeys(db.DefaultContext) + return RewriteAllPrincipalKeys(ctx) } - return asymkey_model.RewriteAllPublicKeys() + return RewriteAllPublicKeys(ctx) } diff --git a/services/asymkey/ssh_key_authorized_keys.go b/services/asymkey/ssh_key_authorized_keys.go new file mode 100644 index 00000000000..5caa5bbfb61 --- /dev/null +++ b/services/asymkey/ssh_key_authorized_keys.go @@ -0,0 +1,79 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again. +// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function +// outside any session scope independently. +func RewriteAllPublicKeys(ctx context.Context) error { + // Don't rewrite key if internal server + if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { + return nil + } + + return asymkey_model.WithSSHOpLocker(func() error { + return rewriteAllPublicKeys(ctx) + }) +} + +func rewriteAllPublicKeys(ctx context.Context) error { + if setting.SSH.RootPath != "" { + // First of ensure that the RootPath is present, and if not make it with 0700 permissions + // This of course doesn't guarantee that this is the right directory for authorized_keys + // but at least if it's supposed to be this directory and it doesn't exist and we're the + // right user it will at least be created properly. + err := os.MkdirAll(setting.SSH.RootPath, 0o700) + if err != nil { + log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) + return err + } + } + + fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + tmpPath := fPath + ".tmp" + t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + t.Close() + if err := util.Remove(tmpPath); err != nil { + log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err) + } + }() + + if setting.SSH.AuthorizedKeysBackup { + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) + if err = util.CopyFile(fPath, bakPath); err != nil { + return err + } + } + } + + if err := asymkey_model.RegeneratePublicKeys(ctx, t); err != nil { + return err + } + + t.Close() + return util.Rename(tmpPath, fPath) +} diff --git a/models/asymkey/ssh_key_authorized_principals.go b/services/asymkey/ssh_key_authorized_principals.go similarity index 69% rename from models/asymkey/ssh_key_authorized_principals.go rename to services/asymkey/ssh_key_authorized_principals.go index 592196c255a..2838bb5fc71 100644 --- a/models/asymkey/ssh_key_authorized_principals.go +++ b/services/asymkey/ssh_key_authorized_principals.go @@ -13,34 +13,25 @@ import ( "strings" "time" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" ) -// _____ __ .__ .__ .___ -// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ -// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | -// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | -// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | -// \/ \/ \/ \/ \/ -// __________ .__ .__ .__ -// \______ _______|__| ____ ____ |_____________ | | ______ -// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/ -// | | | | \| | | \ \___| | |_> / __ \| |__\___ \ -// |____| |__| |__|___| /\___ |__| __(____ |____/____ > -// \/ \/ |__| \/ \/ -// // This file contains functions for creating authorized_principals files // // There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys // The sshOpLocker is used from ssh_key_authorized_keys.go -const authorizedPrincipalsFile = "authorized_principals" +const ( + authorizedPrincipalsFile = "authorized_principals" + tplCommentPrefix = `# gitea public key` +) // RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. -// Note: db.GetEngine(db.DefaultContext).Iterate does not get latest data after insert/delete, so we have to call this function +// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function // outside any session scope independently. func RewriteAllPrincipalKeys(ctx context.Context) error { // Don't rewrite key if internal server @@ -48,9 +39,12 @@ func RewriteAllPrincipalKeys(ctx context.Context) error { return nil } - sshOpLocker.Lock() - defer sshOpLocker.Unlock() + return asymkey_model.WithSSHOpLocker(func() error { + return rewriteAllPrincipalKeys(ctx) + }) +} +func rewriteAllPrincipalKeys(ctx context.Context) error { if setting.SSH.RootPath != "" { // First of ensure that the RootPath is present, and if not make it with 0700 permissions // This of course doesn't guarantee that this is the right directory for authorized_keys @@ -97,8 +91,8 @@ func RewriteAllPrincipalKeys(ctx context.Context) error { } func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { - if err := db.GetEngine(ctx).Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) + if err := db.GetEngine(ctx).Where("type = ?", asymkey_model.KeyTypePrincipal).Iterate(new(asymkey_model.PublicKey), func(idx int, bean any) (err error) { + _, err = t.WriteString((bean.(*asymkey_model.PublicKey)).AuthorizedString()) return err }); err != nil { return err @@ -115,6 +109,8 @@ func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { if err != nil { return err } + defer f.Close() + scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() @@ -124,11 +120,12 @@ func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { } _, err = t.WriteString(line + "\n") if err != nil { - f.Close() return err } } - f.Close() + if err = scanner.Err(); err != nil { + return fmt.Errorf("regeneratePrincipalKeys scan: %w", err) + } } return nil } diff --git a/services/asymkey/ssh_key_principals.go b/services/asymkey/ssh_key_principals.go new file mode 100644 index 00000000000..5ed5cfa7829 --- /dev/null +++ b/services/asymkey/ssh_key_principals.go @@ -0,0 +1,54 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" +) + +// AddPrincipalKey adds new principal to database and authorized_principals file. +func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*asymkey_model.PublicKey, error) { + dbCtx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + // Principals cannot be duplicated. + has, err := db.GetEngine(dbCtx). + Where("content = ? AND type = ?", content, asymkey_model.KeyTypePrincipal). + Get(new(asymkey_model.PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, asymkey_model.ErrKeyAlreadyExist{ + Content: content, + } + } + + key := &asymkey_model.PublicKey{ + OwnerID: ownerID, + Name: content, + Content: content, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypePrincipal, + LoginSourceID: authSourceID, + } + if err = db.Insert(dbCtx, key); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } + + if err = committer.Commit(); err != nil { + return nil, err + } + + committer.Close() + + return key, RewriteAllPrincipalKeys(ctx) +} diff --git a/services/asymkey/ssh_key_test.go b/services/asymkey/ssh_key_test.go index 32c31a43323..fbd5d13ab2d 100644 --- a/services/asymkey/ssh_key_test.go +++ b/services/asymkey/ssh_key_test.go @@ -8,6 +8,7 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -65,8 +66,11 @@ ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ib for i, kase := range testCases { s.ID = int64(i) + 20 - asymkey_model.AddPublicKeysBySource(user, s, []string{kase.keyString}) - keys, err := asymkey_model.ListPublicKeysBySource(user.ID, s.ID) + asymkey_model.AddPublicKeysBySource(db.DefaultContext, user, s, []string{kase.keyString}) + keys, err := db.Find[asymkey_model.PublicKey](db.DefaultContext, asymkey_model.FindPublicKeyOptions{ + OwnerID: user.ID, + LoginSourceID: s.ID, + }) assert.NoError(t, err) if err != nil { continue @@ -77,7 +81,7 @@ ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ib assert.Contains(t, kase.keyContents, key.Content) } for _, key := range keys { - DeletePublicKey(user, key.ID) + DeletePublicKey(db.DefaultContext, user, key.ID) } } } diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 3e7df0cee0c..0fd51e4fa5e 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -12,19 +12,19 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/storage" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context/upload" "github.com/google/uuid" ) // NewAttachment creates a new attachment object, but do not verify. -func NewAttachment(attach *repo_model.Attachment, file io.Reader, size int64) (*repo_model.Attachment, error) { +func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.Reader, size int64) (*repo_model.Attachment, error) { if attach.RepoID == 0 { return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name) } - err := db.WithTx(db.DefaultContext, func(ctx context.Context) error { + err := db.WithTx(ctx, func(ctx context.Context) error { attach.UUID = uuid.New().String() size, err := storage.Attachments.Save(attach.RelativePath(), file, size) if err != nil { @@ -39,14 +39,14 @@ func NewAttachment(attach *repo_model.Attachment, file io.Reader, size int64) (* } // UploadAttachment upload new attachment into storage and update database -func UploadAttachment(file io.Reader, allowedTypes string, fileSize int64, opts *repo_model.Attachment) (*repo_model.Attachment, error) { +func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) { buf := make([]byte, 1024) n, _ := util.ReadAtMost(file, buf) buf = buf[:n] - if err := upload.Verify(buf, opts.Name, allowedTypes); err != nil { + if err := upload.Verify(buf, attach.Name, allowedTypes); err != nil { return nil, err } - return NewAttachment(opts, io.MultiReader(bytes.NewReader(buf), file), fileSize) + return NewAttachment(ctx, attach, io.MultiReader(bytes.NewReader(buf), file), fileSize) } diff --git a/services/attachment/attachment_test.go b/services/attachment/attachment_test.go index 35fcef7445c..142bcfe6292 100644 --- a/services/attachment/attachment_test.go +++ b/services/attachment/attachment_test.go @@ -19,9 +19,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestUploadAttachment(t *testing.T) { @@ -34,7 +32,7 @@ func TestUploadAttachment(t *testing.T) { assert.NoError(t, err) defer f.Close() - attach, err := NewAttachment(&repo_model.Attachment{ + attach, err := NewAttachment(db.DefaultContext, &repo_model.Attachment{ RepoID: 1, UploaderID: user.ID, Name: filepath.Base(fPath), diff --git a/services/auth/auth.go b/services/auth/auth.go index c7fdc56cbed..a2523a2452e 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -10,14 +10,15 @@ import ( "regexp" "strings" - "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/webauthn" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" + gitea_context "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) // Init should be called exactly once when the application starts to allow plugins @@ -37,12 +38,17 @@ func isContainerPath(req *http.Request) bool { } var ( - gitRawReleasePathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/))`) - lfsPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`) + gitRawOrAttachPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`) + lfsPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`) + archivePathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/archive/`) ) -func isGitRawReleaseOrLFSPath(req *http.Request) bool { - if gitRawReleasePathRe.MatchString(req.URL.Path) { +func isGitRawOrAttachPath(req *http.Request) bool { + return gitRawOrAttachPathRe.MatchString(req.URL.Path) +} + +func isGitRawOrAttachOrLFSPath(req *http.Request) bool { + if isGitRawOrAttachPath(req) { return true } if setting.LFS.StartServer { @@ -51,6 +57,10 @@ func isGitRawReleaseOrLFSPath(req *http.Request) bool { return false } +func isArchivePath(req *http.Request) bool { + return archivePathRe.MatchString(req.URL.Path) +} + // handleSignIn clears existing session variables and stores new ones for the specified user object func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) { // We need to regenerate the session... @@ -82,8 +92,10 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore // If the user does not have a locale set, we save the current one. if len(user.Language) == 0 { lc := middleware.Locale(resp, req) - user.Language = lc.Language() - if err := user_model.UpdateUserCols(db.DefaultContext, user, "language"); err != nil { + opts := &user_service.UpdateOptions{ + Language: optional.Some(lc.Language()), + } + if err := user_service.UpdateUser(req.Context(), user, opts); err != nil { log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language)) return } diff --git a/services/auth/auth_test.go b/services/auth/auth_test.go index f4e3cdf0d32..3adaa286644 100644 --- a/services/auth/auth_test.go +++ b/services/auth/auth_test.go @@ -85,6 +85,10 @@ func Test_isGitRawOrLFSPath(t *testing.T) { "/owner/repo/releases/download/tag/repo.tar.gz", true, }, + { + "/owner/repo/attachments/6d92a9ee-5d8b-4993-97c9-6181bdaa8955", + true, + }, } lfsTests := []string{ "/owner/repo/info/lfs/", @@ -104,11 +108,11 @@ func Test_isGitRawOrLFSPath(t *testing.T) { t.Run(tt.path, func(t *testing.T) { req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil) setting.LFS.StartServer = false - if got := isGitRawReleaseOrLFSPath(req); got != tt.want { + if got := isGitRawOrAttachOrLFSPath(req); got != tt.want { t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want) } setting.LFS.StartServer = true - if got := isGitRawReleaseOrLFSPath(req); got != tt.want { + if got := isGitRawOrAttachOrLFSPath(req); got != tt.want { t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want) } }) @@ -117,11 +121,11 @@ func Test_isGitRawOrLFSPath(t *testing.T) { t.Run(tt, func(t *testing.T) { req, _ := http.NewRequest("POST", tt, nil) setting.LFS.StartServer = false - if got := isGitRawReleaseOrLFSPath(req); got != setting.LFS.StartServer { - t.Errorf("isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, gitRawReleasePathRe.MatchString(tt)) + if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer { + t.Errorf("isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, gitRawOrAttachPathRe.MatchString(tt)) } setting.LFS.StartServer = true - if got := isGitRawReleaseOrLFSPath(req); got != setting.LFS.StartServer { + if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer { t.Errorf("isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer) } }) diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go new file mode 100644 index 00000000000..6b59238c984 --- /dev/null +++ b/services/auth/auth_token.go @@ -0,0 +1,123 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "strings" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies + +// The auth token consists of two parts: ID and token hash +// Every device login creates a new auth token with an individual id and hash. +// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash. + +var ( + ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format") + ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired") + ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid") +) + +func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) { + if len(value) == 0 { + return nil, nil + } + + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return nil, ErrAuthTokenInvalidFormat + } + + t, err := auth_model.GetAuthTokenByID(ctx, parts[0]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil, ErrAuthTokenExpired + } + return nil, err + } + + if t.ExpiresUnix < timeutil.TimeStampNow() { + return nil, ErrAuthTokenExpired + } + + hashedToken := sha256.Sum256([]byte(parts[1])) + + if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 { + // If an attacker steals a token and uses the token to create a new session the hash gets updated. + // When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token. + return nil, ErrAuthTokenInvalidHash + } + + return t, nil +} + +func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) { + token, hash, err := generateTokenAndHash() + if err != nil { + return nil, "", err + } + + newToken := &auth_model.AuthToken{ + ID: t.ID, + TokenHash: hash, + UserID: t.UserID, + ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), + } + + if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil { + return nil, "", err + } + + return newToken, token, nil +} + +func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) { + t := &auth_model.AuthToken{ + UserID: userID, + ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), + } + + var err error + t.ID, err = util.CryptoRandomString(10) + if err != nil { + return nil, "", err + } + + token, hash, err := generateTokenAndHash() + if err != nil { + return nil, "", err + } + + t.TokenHash = hash + + if err := auth_model.InsertAuthToken(ctx, t); err != nil { + return nil, "", err + } + + return t, token, nil +} + +func generateTokenAndHash() (string, string, error) { + buf, err := util.CryptoRandomBytes(32) + if err != nil { + return "", "", err + } + + token := hex.EncodeToString(buf) + + hashedToken := sha256.Sum256([]byte(token)) + + return token, hex.EncodeToString(hashedToken[:]), nil +} diff --git a/services/auth/auth_token_test.go b/services/auth/auth_token_test.go new file mode 100644 index 00000000000..23c8d17e597 --- /dev/null +++ b/services/auth/auth_token_test.go @@ -0,0 +1,107 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +func TestCheckAuthToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("Empty", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "") + assert.NoError(t, err) + assert.Nil(t, token) + }) + + t.Run("InvalidFormat", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "dummy") + assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat) + assert.Nil(t, token) + }) + + t.Run("NotFound", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "notexists:dummy") + assert.ErrorIs(t, err, ErrAuthTokenExpired) + assert.Nil(t, token) + }) + + t.Run("Expired", func(t *testing.T) { + timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + timeutil.MockUnset() + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token) + assert.ErrorIs(t, err, ErrAuthTokenExpired) + assert.Nil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) + + t.Run("InvalidHash", func(t *testing.T) { + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token+"dummy") + assert.ErrorIs(t, err, ErrAuthTokenInvalidHash) + assert.Nil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) + + t.Run("Valid", func(t *testing.T) { + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token) + assert.NoError(t, err) + assert.NotNil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) +} + +func TestRegenerateAuthToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + defer timeutil.MockUnset() + + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC)) + + at2, token2, err := RegenerateAuthToken(db.DefaultContext, at) + assert.NoError(t, err) + assert.NotNil(t, at2) + assert.NotEmpty(t, token2) + + assert.Equal(t, at.ID, at2.ID) + assert.Equal(t, at.UserID, at2.UserID) + assert.NotEqual(t, token, token2) + assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) +} diff --git a/services/auth/basic.go b/services/auth/basic.go index bb9eb7a3216..1184d12d1c4 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" ) @@ -42,7 +43,7 @@ func (b *Basic) Name() string { // Returns nil if header is empty or validation fails. func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { // Basic authentication should only fire on API, Download or on Git or LFSPaths - if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawReleaseOrLFSPath(req) { + if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) { return nil, nil } @@ -131,11 +132,30 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore return nil, err } - if skipper, ok := source.Cfg.(LocalTwoFASkipper); ok && skipper.IsSkipLocalTwoFA() { - store.GetData()["SkipLocalTwoFA"] = true + if skipper, ok := source.Cfg.(LocalTwoFASkipper); !ok || !skipper.IsSkipLocalTwoFA() { + if err := validateTOTP(req, u); err != nil { + return nil, err + } } log.Trace("Basic Authorization: Logged in user %-v", u) return u, nil } + +func validateTOTP(req *http.Request, u *user_model.User) error { + twofa, err := auth_model.GetTwoFactorByUID(req.Context(), u.ID) + if err != nil { + if auth_model.IsErrTwoFactorNotEnrolled(err) { + // No 2FA enrollment for this user + return nil + } + return err + } + if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil { + return err + } else if !ok { + return util.NewInvalidArgumentErrorf("invalid provided OTP") + } + return nil +} diff --git a/services/auth/httpsign.go b/services/auth/httpsign.go index d6a64c6fc42..b604349f80d 100644 --- a/services/auth/httpsign.go +++ b/services/auth/httpsign.go @@ -12,6 +12,7 @@ import ( "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -92,7 +93,9 @@ func VerifyPubKey(r *http.Request) (*asymkey_model.PublicKey, error) { keyID := verifier.KeyId() - publicKeys, err := asymkey_model.SearchPublicKey(0, keyID) + publicKeys, err := db.Find[asymkey_model.PublicKey](r.Context(), asymkey_model.FindPublicKeyOptions{ + Fingerprint: keyID, + }) if err != nil { return nil, err } diff --git a/services/auth/main_test.go b/services/auth/main_test.go new file mode 100644 index 00000000000..b81c39a1f25 --- /dev/null +++ b/services/auth/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 6572d661e87..46d85101436 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -14,6 +14,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/auth/source/oauth2" @@ -62,14 +63,19 @@ func (o *OAuth2) Name() string { // representing whether the token exists or not func parseToken(req *http.Request) (string, bool) { _ = req.ParseForm() - // Check token. - if token := req.Form.Get("token"); token != "" { - return token, true - } - // Check access token. - if token := req.Form.Get("access_token"); token != "" { - return token, true + if !setting.DisableQueryAuthToken { + // Check token. + if token := req.Form.Get("token"); token != "" { + return token, true + } + // Check access token. + if token := req.Form.Get("access_token"); token != "" { + return token, true + } + } else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" { + log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true") } + // check header token if auHead := req.Header.Get("Authorization"); auHead != "" { auths := strings.Fields(auHead) @@ -125,7 +131,9 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat // If verification is successful returns an existing user object. // Returns nil if verification fails. func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { - if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) { + // These paths are not API paths, but we still want to check for tokens because they maybe in the API returned URLs + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) && + !isGitRawOrAttachPath(req) && !isArchivePath(req) { return nil, nil } diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index ad525f5c95f..b6aeb0aed2c 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -10,8 +10,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" gouuid "github.com/google/uuid" @@ -117,7 +117,7 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da } // Make sure requests to API paths, attachment downloads, git and LFS do not create a new session - if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawReleaseOrLFSPath(req) { + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) { if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) { handleSignIn(w, req, sess, user) } @@ -161,7 +161,7 @@ func (r *ReverseProxy) newUser(req *http.Request) *user_model.User { } overwriteDefault := user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), } if err := user_model.CreateUser(req.Context(), user, &overwriteDefault); err != nil { diff --git a/services/auth/session.go b/services/auth/session.go index 52cfb8ac213..35d97e42da1 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -6,7 +6,6 @@ package auth import ( "net/http" - "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" ) @@ -29,40 +28,33 @@ func (s *Session) Name() string { // object for that uid. // Returns nil if there is no user uid stored in the session. func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { - user := SessionUser(sess) - if user != nil { - return user, nil - } - return nil, nil -} - -// SessionUser returns the user object corresponding to the "uid" session variable. -func SessionUser(sess SessionStore) *user_model.User { if sess == nil { - return nil + return nil, nil } // Get user ID uid := sess.Get("uid") if uid == nil { - return nil + return nil, nil } log.Trace("Session Authorization: Found user[%d]", uid) id, ok := uid.(int64) if !ok { - return nil + return nil, nil } // Get user object - user, err := user_model.GetUserByID(db.DefaultContext, id) + user, err := user_model.GetUserByID(req.Context(), id) if err != nil { if !user_model.IsErrUserNotExist(err) { - log.Error("GetUserById: %v", err) + log.Error("GetUserByID: %v", err) + // Return the err as-is to keep current signed-in session, in case the err is something like context.Canceled. Otherwise non-existing user (nil, nil) will make the caller clear the signed-in session. + return nil, err } - return nil + return nil, nil } log.Trace("Session Authorization: Logged in user %-v", user) - return user + return user, nil } diff --git a/services/auth/signin.go b/services/auth/signin.go index 6d515ac628a..e116a088e09 100644 --- a/services/auth/signin.go +++ b/services/auth/signin.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/smtp" @@ -56,7 +57,7 @@ func UserSignIn(ctx context.Context, username, password string) (*user_model.Use } if hasUser { - source, err := auth.GetSourceByID(user.LoginSource) + source, err := auth.GetSourceByID(ctx, user.LoginSource) if err != nil { return nil, nil, err } @@ -85,7 +86,9 @@ func UserSignIn(ctx context.Context, username, password string) (*user_model.Use } } - sources, err := auth.AllActiveSources() + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) if err != nil { return nil, nil, err } diff --git a/services/auth/source.go b/services/auth/source.go index aae3a781024..69b71a6deae 100644 --- a/services/auth/source.go +++ b/services/auth/source.go @@ -4,14 +4,16 @@ package auth import ( + "context" + "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" ) // DeleteSource deletes a AuthSource record in DB. -func DeleteSource(source *auth.Source) error { - count, err := db.GetEngine(db.DefaultContext).Count(&user_model.User{LoginSource: source.ID}) +func DeleteSource(ctx context.Context, source *auth.Source) error { + count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID}) if err != nil { return err } else if count > 0 { @@ -20,7 +22,7 @@ func DeleteSource(source *auth.Source) error { } } - count, err = db.GetEngine(db.DefaultContext).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID}) + count, err = db.GetEngine(ctx).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID}) if err != nil { return err } else if count > 0 { @@ -35,6 +37,6 @@ func DeleteSource(source *auth.Source) error { } } - _, err = db.GetEngine(db.DefaultContext).ID(source.ID).Delete(new(auth.Source)) + _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)) return err } diff --git a/services/auth/source/db/source.go b/services/auth/source/db/source.go index 50eae274393..bb2270cbd62 100644 --- a/services/auth/source/db/source.go +++ b/services/auth/source/db/source.go @@ -18,7 +18,7 @@ func (source *Source) FromDB(bs []byte) error { return nil } -// ToDB exports an SMTPConfig to a serialized format. +// ToDB exports the config to a byte slice to be saved into database (this method is just dummy and does nothing for DB source) func (source *Source) ToDB() ([]byte, error) { return nil, nil } diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index dc166d9eb41..6ebd3ea50ac 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -12,7 +12,8 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/optional" + asymkey_service "code.gitea.io/gitea/services/asymkey" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -29,7 +30,13 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u // User not in LDAP, do nothing return nil, user_model.ErrUserNotExist{Name: loginName} } - + // Fallback. + if len(sr.Username) == 0 { + sr.Username = userName + } + if len(sr.Mail) == 0 { + sr.Mail = fmt.Sprintf("%s@localhost.local", sr.Username) + } isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 // Update User admin flag if exist @@ -43,20 +50,17 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u } } if user != nil && !user.ProhibitLogin { - cols := make([]string, 0) + opts := &user_service.UpdateOptions{} if len(source.AdminFilter) > 0 && user.IsAdmin != sr.IsAdmin { // Change existing admin flag only if AdminFilter option is set - user.IsAdmin = sr.IsAdmin - cols = append(cols, "is_admin") + opts.IsAdmin = optional.Some(sr.IsAdmin) } - if !user.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted { + if !sr.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted { // Change existing restricted flag only if RestrictedFilter option is set - user.IsRestricted = sr.IsRestricted - cols = append(cols, "is_restricted") + opts.IsRestricted = optional.Some(sr.IsRestricted) } - if len(cols) > 0 { - err = user_model.UpdateUserCols(ctx, user, cols...) - if err != nil { + if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, user, opts); err != nil { return nil, err } } @@ -64,21 +68,12 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u } if user != nil { - if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return user, err } } } else { - // Fallback. - if len(sr.Username) == 0 { - sr.Username = userName - } - - if len(sr.Mail) == 0 { - sr.Mail = fmt.Sprintf("%s@localhost.local", sr.Username) - } - user = &user_model.User{ LowerName: strings.ToLower(sr.Username), Name: sr.Username, @@ -90,8 +85,8 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u IsAdmin: sr.IsAdmin, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsRestricted: util.OptionalBoolOf(sr.IsRestricted), - IsActive: util.OptionalBoolTrue, + IsRestricted: optional.Some(sr.IsRestricted), + IsActive: optional.Some(true), } err := user_model.CreateUser(ctx, user, overwriteDefault) @@ -99,13 +94,13 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u return user, err } - if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return user, err } } if len(source.AttributeAvatar) > 0 { - if err := user_service.UploadAvatar(user, sr.Avatar); err != nil { + if err := user_service.UploadAvatar(ctx, user, sr.Avatar); err != nil { return user, err } } diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 8fb1363fc29..0c9491cd098 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -15,7 +15,8 @@ import ( auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/optional" + asymkey_service "code.gitea.io/gitea/services/asymkey" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -77,7 +78,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.authSource.Name) // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed if sshKeysNeedUpdate { - err = asymkey_model.RewriteAllPublicKeys() + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { log.Error("RewriteAllPublicKeys: %v", err) } @@ -124,8 +125,8 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { IsAdmin: su.IsAdmin, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsRestricted: util.OptionalBoolOf(su.IsRestricted), - IsActive: util.OptionalBoolTrue, + IsRestricted: optional.Some(su.IsRestricted), + IsActive: optional.Some(true), } err = user_model.CreateUser(ctx, usr, overwriteDefault) @@ -135,17 +136,17 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { if err == nil && isAttributeSSHPublicKeySet { log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.authSource.Name, usr.Name) - if asymkey_model.AddPublicKeysBySource(usr, source.authSource, su.SSHPublicKey) { + if asymkey_model.AddPublicKeysBySource(ctx, usr, source.authSource, su.SSHPublicKey) { sshKeysNeedUpdate = true } } if err == nil && len(source.AttributeAvatar) > 0 { - _ = user_service.UploadAvatar(usr, su.Avatar) + _ = user_service.UploadAvatar(ctx, usr, su.Avatar) } } else if updateExisting { // Synchronize SSH Public Key if that attribute is set - if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(usr, source.authSource, su.SSHPublicKey) { + if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.authSource, su.SSHPublicKey) { sshKeysNeedUpdate = true } @@ -158,28 +159,30 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { log.Trace("SyncExternalUsers[%s]: Updating user %s", source.authSource.Name, usr.Name) - usr.FullName = fullName - emailChanged := usr.Email != su.Mail - usr.Email = su.Mail - // Change existing admin flag only if AdminFilter option is set - if len(source.AdminFilter) > 0 { - usr.IsAdmin = su.IsAdmin + opts := &user_service.UpdateOptions{ + FullName: optional.Some(fullName), + IsActive: optional.Some(true), + } + if source.AdminFilter != "" { + opts.IsAdmin = optional.Some(su.IsAdmin) } // Change existing restricted flag only if RestrictedFilter option is set - if !usr.IsAdmin && len(source.RestrictedFilter) > 0 { - usr.IsRestricted = su.IsRestricted + if !su.IsAdmin && source.RestrictedFilter != "" { + opts.IsRestricted = optional.Some(su.IsRestricted) } - usr.IsActive = true - err = user_model.UpdateUser(ctx, usr, emailChanged, "full_name", "email", "is_admin", "is_restricted", "is_active") - if err != nil { + if err := user_service.UpdateUser(ctx, usr, opts); err != nil { log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.authSource.Name, usr.Name, err) } + + if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil { + log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.authSource.Name, usr.Name, su.Mail, err) + } } if usr.IsUploadAvatarChanged(su.Avatar) { if err == nil && len(source.AttributeAvatar) > 0 { - _ = user_service.UploadAvatar(usr, su.Avatar) + _ = user_service.UploadAvatar(ctx, usr, su.Avatar) } } } @@ -193,7 +196,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed if sshKeysNeedUpdate { - err = asymkey_model.RewriteAllPublicKeys() + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { log.Error("RewriteAllPublicKeys: %v", err) } @@ -215,9 +218,10 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.authSource.Name, usr.Name) - usr.IsActive = false - err = user_model.UpdateUserCols(ctx, usr, "is_active") - if err != nil { + opts := &user_service.UpdateOptions{ + IsActive: optional.Some(false), + } + if err := user_service.UpdateUser(ctx, usr, opts); err != nil { log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.authSource.Name, usr.Name, err) } } diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go index 32fe545c906..5c256815486 100644 --- a/services/auth/source/oauth2/init.go +++ b/services/auth/source/oauth2/init.go @@ -4,12 +4,15 @@ package oauth2 import ( + "context" "encoding/gob" "net/http" "sync" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "github.com/google/uuid" @@ -26,7 +29,7 @@ const UsersStoreKey = "gitea-oauth2-sessions" const ProviderHeaderKey = "gitea-oauth2-provider" // Init initializes the oauth source -func Init() error { +func Init(ctx context.Context) error { if err := InitSigningKey(); err != nil { return err } @@ -51,18 +54,24 @@ func Init() error { // Unlock our mutex gothRWMutex.Unlock() - return initOAuth2Sources() + return initOAuth2Sources(ctx) } // ResetOAuth2 clears existing OAuth2 providers and loads them from DB -func ResetOAuth2() error { +func ResetOAuth2(ctx context.Context) error { ClearProviders() - return initOAuth2Sources() + return initOAuth2Sources(ctx) } // initOAuth2Sources is used to load and register all active OAuth2 providers -func initOAuth2Sources() error { - authSources, _ := auth.GetActiveOAuth2ProviderSources() +func initOAuth2Sources(ctx context.Context) error { + authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + LoginType: auth.OAuth2, + }) + if err != nil { + return err + } for _, source := range authSources { oauth2Source, ok := source.Cfg.(*Source) if !ok { diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go index eca0b8b7e12..070fffe60f7 100644 --- a/services/auth/source/oauth2/jwtsigningkey.go +++ b/services/auth/source/oauth2/jwtsigningkey.go @@ -300,7 +300,7 @@ func InitSigningKey() error { case "HS384": fallthrough case "HS512": - key, err = loadSymmetricKey() + key = setting.GetGeneralTokenSigningSecret() case "RS256": fallthrough case "RS384": @@ -333,12 +333,6 @@ func InitSigningKey() error { return nil } -// loadSymmetricKey checks if the configured secret is valid. -// If it is not valid, it will return an error. -func loadSymmetricKey() (any, error) { - return util.Base64FixedDecode(base64.RawURLEncoding, []byte(setting.OAuth2.JWTSecretBase64), 32) -} - // loadOrCreateAsymmetricKey checks if the configured private key exists. // If it does not exist a new random key gets generated and saved on the configured path. func loadOrCreateAsymmetricKey() (any, error) { diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go index 7572aa20c0a..6ed6c184eb5 100644 --- a/services/auth/source/oauth2/providers.go +++ b/services/auth/source/oauth2/providers.go @@ -4,6 +4,7 @@ package oauth2 import ( + "context" "errors" "fmt" "html" @@ -12,7 +13,9 @@ import ( "sort" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "github.com/markbates/goth" @@ -22,7 +25,7 @@ import ( type Provider interface { Name() string DisplayName() string - IconHTML() template.HTML + IconHTML(size int) template.HTML CustomURLSettings() *CustomURLSettings } @@ -54,14 +57,16 @@ func (p *AuthSourceProvider) DisplayName() string { return p.sourceName } -func (p *AuthSourceProvider) IconHTML() template.HTML { +func (p *AuthSourceProvider) IconHTML(size int) template.HTML { if p.iconURL != "" { - img := fmt.Sprintf(`%s`, + img := fmt.Sprintf(`%s`, + size, + size, html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()), ) return template.HTML(img) } - return p.GothProvider.IconHTML() + return p.GothProvider.IconHTML(size) } // Providers contains the map of registered OAuth2 providers in Gitea (based on goth) @@ -77,10 +82,10 @@ func RegisterGothProvider(provider GothProvider) { gothProviders[provider.Name()] = provider } -// GetOAuth2Providers returns the map of unconfigured OAuth2 providers +// GetSupportedOAuth2Providers returns the map of unconfigured OAuth2 providers // key is used as technical name (like in the callbackURL) // values to display -func GetOAuth2Providers() []Provider { +func GetSupportedOAuth2Providers() []Provider { providers := make([]Provider, 0, len(gothProviders)) for _, provider := range gothProviders { @@ -92,33 +97,39 @@ func GetOAuth2Providers() []Provider { return providers } -// GetActiveOAuth2Providers returns the map of configured active OAuth2 providers -// key is used as technical name (like in the callbackURL) -// values to display -func GetActiveOAuth2Providers() ([]string, map[string]Provider, error) { - // Maybe also separate used and unused providers so we can force the registration of only 1 active provider for each type +func CreateProviderFromSource(source *auth.Source) (Provider, error) { + oauth2Cfg, ok := source.Cfg.(*Source) + if !ok { + return nil, fmt.Errorf("invalid OAuth2 source config: %v", oauth2Cfg) + } + gothProv := gothProviders[oauth2Cfg.Provider] + return &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL}, nil +} - authSources, err := auth.GetActiveOAuth2ProviderSources() +// GetOAuth2Providers returns the list of configured OAuth2 providers +func GetOAuth2Providers(ctx context.Context, isActive optional.Option[bool]) ([]Provider, error) { + authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: isActive, + LoginType: auth.OAuth2, + }) if err != nil { - return nil, nil, err + return nil, err } - var orderedKeys []string - providers := make(map[string]Provider) + providers := make([]Provider, 0, len(authSources)) for _, source := range authSources { - oauth2Cfg, ok := source.Cfg.(*Source) - if !ok { - log.Error("Invalid OAuth2 source config: %v", oauth2Cfg) - continue + provider, err := CreateProviderFromSource(source) + if err != nil { + return nil, err } - gothProv := gothProviders[oauth2Cfg.Provider] - providers[source.Name] = &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL} - orderedKeys = append(orderedKeys, source.Name) + providers = append(providers, provider) } - sort.Strings(orderedKeys) + sort.Slice(providers, func(i, j int) bool { + return providers[i].Name() < providers[j].Name() + }) - return orderedKeys, providers, nil + return providers, nil } // RegisterProviderWithGothic register a OAuth2 provider in goth lib diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go index 5ba06febaf7..9d4ab106e5c 100644 --- a/services/auth/source/oauth2/providers_base.go +++ b/services/auth/source/oauth2/providers_base.go @@ -27,7 +27,7 @@ func (b *BaseProvider) DisplayName() string { } // IconHTML returns icon HTML for this provider -func (b *BaseProvider) IconHTML() template.HTML { +func (b *BaseProvider) IconHTML(size int) template.HTML { svgName := "gitea-" + b.name switch b.name { case "gplus": @@ -35,10 +35,10 @@ func (b *BaseProvider) IconHTML() template.HTML { case "github": svgName = "octicon-mark-github" } - svgHTML := svg.RenderHTML(svgName, 20, "gt-mr-3") + svgHTML := svg.RenderHTML(svgName, size, "tw-mr-2") if svgHTML == "" { log.Error("No SVG icon for oauth2 provider %q", b.name) - svgHTML = svg.RenderHTML("gitea-openid", 20, "gt-mr-3") + svgHTML = svg.RenderHTML("gitea-openid", size, "tw-mr-2") } return svgHTML } diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go index 54530ae8a85..285876d5ac3 100644 --- a/services/auth/source/oauth2/providers_openid.go +++ b/services/auth/source/oauth2/providers_openid.go @@ -28,8 +28,8 @@ func (o *OpenIDProvider) DisplayName() string { } // IconHTML returns icon HTML for this provider -func (o *OpenIDProvider) IconHTML() template.HTML { - return svg.RenderHTML("gitea-openid", 20, "gt-mr-3") +func (o *OpenIDProvider) IconHTML(size int) template.HTML { + return svg.RenderHTML("gitea-openid", size, "tw-mr-2") } // CreateGothProvider creates a GothProvider from this Provider diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go index 0891a86392c..addd1bd2c95 100644 --- a/services/auth/source/pam/source_authenticate.go +++ b/services/auth/source/pam/source_authenticate.go @@ -11,8 +11,8 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/pam" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "github.com/google/uuid" ) @@ -60,7 +60,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u LoginName: userName, // This is what the user typed in } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), } if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go index 9a8ada7350f..02f97c4bff2 100644 --- a/services/auth/source/smtp/source_authenticate.go +++ b/services/auth/source/smtp/source_authenticate.go @@ -12,6 +12,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/util" ) @@ -72,7 +73,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u LoginName: userName, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), } if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index 3a2411ec55f..05293f202f5 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -100,12 +100,12 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam } if action == syncAdd && !isMember { - if err := models.AddTeamMember(ctx, team, user.ID); err != nil { + if err := models.AddTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not add user to team: %v", err) return err } } else if action == syncRemove && isMember { - if err := models.RemoveTeamMember(ctx, team, user.ID); err != nil { + if err := models.RemoveTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not remove user from team: %v", err) return err } diff --git a/services/auth/sspi_windows.go b/services/auth/sspi.go similarity index 82% rename from services/auth/sspi_windows.go rename to services/auth/sspi.go index e29bd715293..64a127e97a6 100644 --- a/services/auth/sspi_windows.go +++ b/services/auth/sspi.go @@ -11,30 +11,32 @@ import ( "sync" "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/avatars" + "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/auth/source/sspi" + gitea_context "code.gitea.io/gitea/services/context" gouuid "github.com/google/uuid" - "github.com/quasoft/websspi" ) const ( tplSignIn base.TplName = "user/auth/signin" ) +type SSPIAuth interface { + AppendAuthenticateHeader(w http.ResponseWriter, data string) + Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error) +} + var ( - // sspiAuth is a global instance of the websspi authentication package, - // which is used to avoid acquiring the server credential handle on - // every request - sspiAuth *websspi.Authenticator - sspiAuthOnce sync.Once + sspiAuth SSPIAuth // a global instance of the websspi authenticator to avoid acquiring the server credential handle on every request + sspiAuthOnce sync.Once + sspiAuthErrInit error // Ensure the struct implements the interface. _ Method = &SSPI{} @@ -42,8 +44,9 @@ var ( // SSPI implements the SingleSignOn interface and authenticates requests // via the built-in SSPI module in Windows for SPNEGO authentication. -// On successful authentication returns a valid user object. -// Returns nil if authentication fails. +// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation +// fails (or if negotiation should continue), which would prevent other authentication methods +// to execute at all. type SSPI struct{} // Name represents the name of auth method @@ -56,20 +59,15 @@ func (s *SSPI) Name() string { // If negotiation should continue or authentication fails, immediately returns a 401 HTTP // response code, as required by the SPNEGO protocol. func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { - var errInit error - sspiAuthOnce.Do(func() { - config := websspi.NewConfig() - sspiAuth, errInit = websspi.New(config) - }) - if errInit != nil { - return nil, errInit + sspiAuthOnce.Do(func() { sspiAuthErrInit = sspiAuthInit() }) + if sspiAuthErrInit != nil { + return nil, sspiAuthErrInit } - if !s.shouldAuthenticate(req) { return nil, nil } - cfg, err := s.getConfig() + cfg, err := s.getConfig(req.Context()) if err != nil { log.Error("could not get SSPI config: %v", err) return nil, err @@ -131,8 +129,11 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, } // getConfig retrieves the SSPI configuration from login sources -func (s *SSPI) getConfig() (*sspi.Source, error) { - sources, err := auth.ActiveSources(auth.SSPI) +func (s *SSPI) getConfig(ctx context.Context) (*sspi.Source, error) { + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + LoginType: auth.SSPI, + }) if err != nil { return nil, err } @@ -165,17 +166,14 @@ func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) { func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (*user_model.User, error) { email := gouuid.New().String() + "@localhost.localdomain" user := &user_model.User{ - Name: username, - Email: email, - Passwd: gouuid.New().String(), - Language: cfg.DefaultLanguage, - UseCustomAvatar: true, - Avatar: avatars.DefaultAvatarLink(), + Name: username, + Email: email, + Language: cfg.DefaultLanguage, } emailNotificationPreference := user_model.EmailNotificationsDisabled overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolOf(cfg.AutoActivateUsers), - KeepEmailPrivate: util.OptionalBoolTrue, + IsActive: optional.Some(cfg.AutoActivateUsers), + KeepEmailPrivate: optional.Some(true), EmailNotificationsPreference: &emailNotificationPreference, } if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { diff --git a/services/auth/sspiauth_posix.go b/services/auth/sspiauth_posix.go new file mode 100644 index 00000000000..49b0ed4a52a --- /dev/null +++ b/services/auth/sspiauth_posix.go @@ -0,0 +1,30 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package auth + +import ( + "errors" + "net/http" +) + +type SSPIUserInfo struct { + Username string // Name of user, usually in the form DOMAIN\User + Groups []string // The global groups the user is a member of +} + +type sspiAuthMock struct{} + +func (s sspiAuthMock) AppendAuthenticateHeader(w http.ResponseWriter, data string) { +} + +func (s sspiAuthMock) Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error) { + return nil, "", errors.New("not implemented") +} + +func sspiAuthInit() error { + sspiAuth = &sspiAuthMock{} // TODO: we can mock the SSPI auth in tests + return nil +} diff --git a/services/auth/sspiauth_windows.go b/services/auth/sspiauth_windows.go new file mode 100644 index 00000000000..093caaed33c --- /dev/null +++ b/services/auth/sspiauth_windows.go @@ -0,0 +1,19 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package auth + +import ( + "github.com/quasoft/websspi" +) + +type SSPIUserInfo = websspi.UserInfo + +func sspiAuthInit() error { + var err error + config := websspi.NewConfig() + sspiAuth, err = websspi.New(config) + return err +} diff --git a/services/auth/sync.go b/services/auth/sync.go index e42e8a51a75..7562ac812bd 100644 --- a/services/auth/sync.go +++ b/services/auth/sync.go @@ -15,7 +15,7 @@ import ( func SyncExternalUsers(ctx context.Context, updateExisting bool) error { log.Trace("Doing: SyncExternalUsers") - ls, err := auth.Sources() + ls, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{}) if err != nil { log.Error("SyncExternalUsers: %v", err) return err diff --git a/services/automerge/automerge.go b/services/automerge/automerge.go index bf713c44314..bd427bef9f7 100644 --- a/services/automerge/automerge.go +++ b/services/automerge/automerge.go @@ -17,6 +17,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -111,7 +112,7 @@ func MergeScheduledPullRequest(ctx context.Context, sha string, repo *repo_model } func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) { - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { return nil, err } @@ -190,7 +191,7 @@ func handlePull(pullID int64, sha string) { return } - headGitRepo, err := git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { log.Error("OpenRepository %-v: %v", pr.HeadRepo, err) return @@ -246,7 +247,7 @@ func handlePull(pullID int64, sha string) { return } - baseGitRepo, err = git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { log.Error("OpenRepository %-v: %v", pr.BaseRepo, err) return diff --git a/modules/context/access_log.go b/services/context/access_log.go similarity index 100% rename from modules/context/access_log.go rename to services/context/access_log.go diff --git a/modules/context/api.go b/services/context/api.go similarity index 90% rename from modules/context/api.go rename to services/context/api.go index 044ec51b565..b18a206b5e2 100644 --- a/modules/context/api.go +++ b/services/context/api.go @@ -11,12 +11,11 @@ import ( "net/url" "strings" - "code.gitea.io/gitea/models/auth" - repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" mc "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -101,6 +100,12 @@ type APIRedirect struct{} // swagger:response string type APIString string +// APIRepoArchivedError is an error that is raised when an archived repo should be modified +// swagger:response repoArchivedError +type APIRepoArchivedError struct { + APIError +} + // ServerError responds with error message, status is 500 func (ctx *APIContext) ServerError(title string, err error) { ctx.Error(http.StatusInternalServerError, title, err) @@ -205,32 +210,6 @@ func (ctx *APIContext) SetLinkHeader(total, pageSize int) { } } -// CheckForOTP validates OTP -func (ctx *APIContext) CheckForOTP() { - if skip, ok := ctx.Data["SkipLocalTwoFA"]; ok && skip.(bool) { - return // Skip 2FA - } - - otpHeader := ctx.Req.Header.Get("X-Gitea-OTP") - twofa, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) - if err != nil { - if auth.IsErrTwoFactorNotEnrolled(err) { - return // No 2FA enrollment for this user - } - ctx.Error(http.StatusInternalServerError, "GetTwoFactorByUID", err) - return - } - ok, err := twofa.ValidateTOTP(otpHeader) - if err != nil { - ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err) - return - } - if !ok { - ctx.Error(http.StatusUnauthorized, "", nil) - return - } -} - // APIContexter returns apicontext as middleware func APIContexter() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -245,7 +224,7 @@ func APIContexter() func(http.Handler) http.Handler { defer baseCleanUp() ctx.Base.AppendContextValue(apiContextKey, ctx) - ctx.Base.AppendContextValueFunc(git.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) + ctx.Base.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) // If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid. if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") { @@ -266,7 +245,7 @@ func APIContexter() func(http.Handler) http.Handler { // NotFound handles 404s for APIContext // String will replace message, errors will be added to a slice func (ctx *APIContext) NotFound(objs ...any) { - message := ctx.Tr("error.not_found") + message := ctx.Locale.TrString("error.not_found") var errors []string for _, obj := range objs { // Ignore nil @@ -299,10 +278,9 @@ func ReferencesGitRepo(allowEmpty ...bool) func(ctx *APIContext) (cancel context // For API calls. if ctx.Repo.GitRepo == nil { - repoPath := repo_model.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) - gitRepo, err := git.OpenRepository(ctx, repoPath) + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { - ctx.Error(http.StatusInternalServerError, "RepoRef Invalid repo "+repoPath, err) + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return cancel } ctx.Repo.GitRepo = gitRepo @@ -346,8 +324,8 @@ func RepoRefForAPI(next http.Handler) http.Handler { return } - var err error refName := getRefName(ctx.Base, ctx.Repo, RepoRefAny) + var err error if ctx.Repo.GitRepo.IsBranchExist(refName) { ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName) @@ -363,7 +341,7 @@ func RepoRefForAPI(next http.Handler) http.Handler { return } ctx.Repo.CommitID = ctx.Repo.Commit.ID.String() - } else if len(refName) == git.SHAFullLength { + } else if len(refName) == ctx.Repo.GetObjectFormat().FullLength() { ctx.Repo.CommitID = refName ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetCommit(refName) if err != nil { diff --git a/modules/context/api_org.go b/services/context/api_org.go similarity index 100% rename from modules/context/api_org.go rename to services/context/api_org.go diff --git a/modules/context/api_test.go b/services/context/api_test.go similarity index 100% rename from modules/context/api_test.go rename to services/context/api_test.go diff --git a/modules/context/base.go b/services/context/base.go similarity index 89% rename from modules/context/base.go rename to services/context/base.go index 8df1dde866d..62fb743714b 100644 --- a/modules/context/base.go +++ b/services/context/base.go @@ -6,6 +6,7 @@ package context import ( "context" "fmt" + "html/template" "io" "net/http" "net/url" @@ -16,8 +17,8 @@ import ( "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/translation" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "github.com/go-chi/chi/v5" @@ -206,17 +207,17 @@ func (b *Base) FormBool(key string) bool { return v } -// FormOptionalBool returns an OptionalBoolTrue or OptionalBoolFalse if the value -// for the provided key exists in the form else it returns OptionalBoolNone -func (b *Base) FormOptionalBool(key string) util.OptionalBool { +// FormOptionalBool returns an optional.Some(true) or optional.Some(false) if the value +// for the provided key exists in the form else it returns optional.None[bool]() +func (b *Base) FormOptionalBool(key string) optional.Option[bool] { value := b.Req.FormValue(key) if len(value) == 0 { - return util.OptionalBoolNone + return optional.None[bool]() } s := b.Req.FormValue(key) v, _ := strconv.ParseBool(s) v = v || strings.EqualFold(s, "on") - return util.OptionalBoolOf(v) + return optional.Some(v) } func (b *Base) SetFormString(key, value string) { @@ -255,7 +256,7 @@ func (b *Base) Redirect(location string, status ...int) { code = status[0] } - if strings.Contains(location, "://") || strings.HasPrefix(location, "//") { + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") || strings.HasPrefix(location, "//") { // Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path // 1. the first request to "/my-path" contains cookie // 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking) @@ -264,6 +265,14 @@ func (b *Base) Redirect(location string, status ...int) { // So in this case, we should remove the session cookie from the response header removeSessionCookieHeader(b.Resp) } + // in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx + if b.Req.Header.Get("HX-Request") == "true" { + b.Resp.Header().Set("HX-Redirect", location) + // we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect + // so as to give htmx redirect logic a chance to run + b.Status(http.StatusNoContent) + return + } http.Redirect(b.Resp, b.Req, location, code) } @@ -286,11 +295,11 @@ func (b *Base) cleanUp() { } } -func (b *Base) Tr(msg string, args ...any) string { +func (b *Base) Tr(msg string, args ...any) template.HTML { return b.Locale.Tr(msg, args...) } -func (b *Base) TrN(cnt any, key1, keyN string, args ...any) string { +func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML { return b.Locale.TrN(cnt, key1, keyN, args...) } diff --git a/services/context/base_test.go b/services/context/base_test.go new file mode 100644 index 00000000000..823f20e00bc --- /dev/null +++ b/services/context/base_test.go @@ -0,0 +1,47 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func TestRedirect(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + + cases := []struct { + url string + keep bool + }{ + {"http://test", false}, + {"https://test", false}, + {"//test", false}, + {"/://test", true}, + {"/test", true}, + } + for _, c := range cases { + resp := httptest.NewRecorder() + b, cleanup := NewBaseContext(resp, req) + resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String()) + b.Redirect(c.url) + cleanup() + has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy" + assert.Equal(t, c.keep, has, "url = %q", c.url) + } + + req, _ = http.NewRequest("GET", "/", nil) + resp := httptest.NewRecorder() + req.Header.Add("HX-Request", "true") + b, cleanup := NewBaseContext(resp, req) + b.Redirect("/other") + cleanup() + assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) + assert.Equal(t, http.StatusNoContent, resp.Code) +} diff --git a/modules/context/captcha.go b/services/context/captcha.go similarity index 95% rename from modules/context/captcha.go rename to services/context/captcha.go index a1999900c94..fa8d779f565 100644 --- a/modules/context/captcha.go +++ b/services/context/captcha.go @@ -79,11 +79,11 @@ func VerifyCaptcha(ctx *Context, tpl base.TplName, form any) { case setting.CfTurnstile: valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField)) default: - ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) + ctx.ServerError("Unknown Captcha Type", fmt.Errorf("unknown Captcha Type: %s", setting.Service.CaptchaType)) return } if err != nil { - log.Debug("%v", err) + log.Debug("Captcha Verify failed: %v", err) } if !valid { diff --git a/modules/context/context.go b/services/context/context.go similarity index 91% rename from modules/context/context.go rename to services/context/context.go index 47ad310b095..4b318f7e338 100644 --- a/modules/context/context.go +++ b/services/context/context.go @@ -6,7 +6,8 @@ package context import ( "context" - "html" + "encoding/hex" + "fmt" "html/template" "io" "net/http" @@ -17,7 +18,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" mc "code.gitea.io/gitea/modules/cache" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" @@ -71,16 +72,6 @@ func init() { }) } -// TrHTMLEscapeArgs runs ".Locale.Tr()" but pre-escapes all arguments with html.EscapeString. -// This is useful if the locale message is intended to only produce HTML content. -func (ctx *Context) TrHTMLEscapeArgs(msg string, args ...string) string { - trArgs := make([]any, len(args)) - for i, arg := range args { - trArgs[i] = html.EscapeString(arg) - } - return ctx.Locale.Tr(msg, trArgs...) -} - type webContextKeyType struct{} var WebContextKey = webContextKeyType{} @@ -134,7 +125,7 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context { func Contexter() func(next http.Handler) http.Handler { rnd := templates.HTMLRenderer() csrfOpts := CsrfOptions{ - Secret: setting.SecretKey, + Secret: hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), Cookie: setting.CSRFCookieName, SetCookie: true, Secure: setting.SessionConfig.Secure, @@ -157,14 +148,13 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["Context"] = ctx // TODO: use "ctx" in template and remove this ctx.Data["CurrentURL"] = setting.AppSubURL + req.URL.RequestURI() ctx.Data["Link"] = ctx.Link - ctx.Data["locale"] = ctx.Locale // PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules ctx.PageData = map[string]any{} ctx.Data["PageData"] = ctx.PageData ctx.Base.AppendContextValue(WebContextKey, ctx) - ctx.Base.AppendContextValueFunc(git.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) + ctx.Base.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo }) ctx.Csrf = PrepareCSRFProtector(csrfOpts, ctx) @@ -202,6 +192,7 @@ func Contexter() func(next http.Handler) http.Handler { httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform") ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) + ctx.Data["SystemConfig"] = setting.Config() ctx.Data["CsrfToken"] = ctx.Csrf.GetToken() ctx.Data["CsrfTokenHtml"] = template.HTML(``) @@ -254,6 +245,13 @@ func (ctx *Context) JSONOK() { ctx.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it } -func (ctx *Context) JSONError(msg string) { - ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg}) +func (ctx *Context) JSONError(msg any) { + switch v := msg.(type) { + case string: + ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "text"}) + case template.HTML: + ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "html"}) + default: + panic(fmt.Sprintf("unsupported type: %T", msg)) + } } diff --git a/services/context/context_cookie.go b/services/context/context_cookie.go new file mode 100644 index 00000000000..b6f8dadb566 --- /dev/null +++ b/services/context/context_cookie.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" +) + +const CookieNameFlash = "gitea_flash" + +func removeSessionCookieHeader(w http.ResponseWriter) { + cookies := w.Header()["Set-Cookie"] + w.Header().Del("Set-Cookie") + for _, cookie := range cookies { + if strings.HasPrefix(cookie, setting.SessionConfig.CookieName+"=") { + continue + } + w.Header().Add("Set-Cookie", cookie) + } +} + +// SetSiteCookie convenience function to set most cookies consistently +// CSRF and a few others are the exception here +func (ctx *Context) SetSiteCookie(name, value string, maxAge int) { + middleware.SetSiteCookie(ctx.Resp, name, value, maxAge) +} + +// DeleteSiteCookie convenience function to delete most cookies consistently +// CSRF and a few others are the exception here +func (ctx *Context) DeleteSiteCookie(name string) { + middleware.SetSiteCookie(ctx.Resp, name, "", -1) +} + +// GetSiteCookie returns given cookie value from request header. +func (ctx *Context) GetSiteCookie(name string) string { + return middleware.GetSiteCookie(ctx.Req, name) +} diff --git a/modules/context/context_model.go b/services/context/context_model.go similarity index 100% rename from modules/context/context_model.go rename to services/context/context_model.go diff --git a/modules/context/context_request.go b/services/context/context_request.go similarity index 100% rename from modules/context/context_request.go rename to services/context/context_request.go diff --git a/modules/context/context_response.go b/services/context/context_response.go similarity index 80% rename from modules/context/context_response.go rename to services/context/context_response.go index 5729865561e..d7fd18acacf 100644 --- a/modules/context/context_response.go +++ b/services/context/context_response.go @@ -6,6 +6,7 @@ package context import ( "errors" "fmt" + "html/template" "net" "net/http" "net/url" @@ -43,14 +44,14 @@ func RedirectToUser(ctx *Base, userName string, redirectUserID int64) { ctx.Redirect(path.Join(setting.AppSubURL, redirectPath), http.StatusTemporaryRedirect) } -// RedirectToFirst redirects to first not empty URL -func (ctx *Context) RedirectToFirst(location ...string) { +// RedirectToCurrentSite redirects to first not empty URL which belongs to current site +func (ctx *Context) RedirectToCurrentSite(location ...string) { for _, loc := range location { if len(loc) == 0 { continue } - if httplib.IsRiskyRedirectURL(loc) { + if !httplib.IsCurrentGiteaSiteURL(loc) { continue } @@ -90,20 +91,33 @@ func (ctx *Context) HTML(status int, name base.TplName) { } } -// RenderToString renders the template content to a string -func (ctx *Context) RenderToString(name base.TplName, data map[string]any) (string, error) { +// JSONTemplate renders the template as JSON response +// keep in mind that the template is processed in HTML context, so JSON-things should be handled carefully, eg: by JSEscape +func (ctx *Context) JSONTemplate(tmpl base.TplName) { + t, err := ctx.Render.TemplateLookup(string(tmpl), nil) + if err != nil { + ctx.ServerError("unable to find template", err) + return + } + ctx.Resp.Header().Set("Content-Type", "application/json") + if err = t.Execute(ctx.Resp, ctx.Data); err != nil { + ctx.ServerError("unable to execute template", err) + } +} + +// RenderToHTML renders the template content to a HTML string +func (ctx *Context) RenderToHTML(name base.TplName, data map[string]any) (template.HTML, error) { var buf strings.Builder - err := ctx.Render.HTML(&buf, http.StatusOK, string(name), data, ctx.TemplateContext) - return buf.String(), err + err := ctx.Render.HTML(&buf, 0, string(name), data, ctx.TemplateContext) + return template.HTML(buf.String()), err } // RenderWithErr used for page has form validation but need to prompt error to users. -func (ctx *Context) RenderWithErr(msg string, tpl base.TplName, form any) { +func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) { if form != nil { middleware.AssignForm(form, ctx.Data) } - ctx.Flash.ErrorMsg = msg - ctx.Data["Flash"] = ctx.Flash + ctx.Flash.Error(msg, true) ctx.HTML(http.StatusOK, tpl) } diff --git a/modules/context/context_template.go b/services/context/context_template.go similarity index 53% rename from modules/context/context_template.go rename to services/context/context_template.go index ba90fc170a3..7878d409caf 100644 --- a/modules/context/context_template.go +++ b/services/context/context_template.go @@ -5,10 +5,7 @@ package context import ( "context" - "errors" "time" - - "code.gitea.io/gitea/modules/log" ) var _ context.Context = TemplateContext(nil) @@ -36,14 +33,3 @@ func (c TemplateContext) Err() error { func (c TemplateContext) Value(key any) any { return c.parentContext().Value(key) } - -// DataRaceCheck checks whether the template context function "ctx()" returns the consistent context -// as the current template's rendering context (request context), to help to find data race issues as early as possible. -// When the code is proven to be correct and stable, this function should be removed. -func (c TemplateContext) DataRaceCheck(dataCtx context.Context) (string, error) { - if c.parentContext() != dataCtx { - log.Error("TemplateContext.DataRaceCheck: parent context mismatch\n%s", log.Stack(2)) - return "", errors.New("parent context mismatch") - } - return "", nil -} diff --git a/services/context/context_test.go b/services/context/context_test.go new file mode 100644 index 00000000000..984593398d4 --- /dev/null +++ b/services/context/context_test.go @@ -0,0 +1,51 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRemoveSessionCookieHeader(t *testing.T) { + w := httptest.NewRecorder() + w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String()) + w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String()) + assert.Len(t, w.Header().Values("Set-Cookie"), 2) + removeSessionCookieHeader(w) + assert.Len(t, w.Header().Values("Set-Cookie"), 1) + assert.Contains(t, "other=bar", w.Header().Get("Set-Cookie")) +} + +func TestRedirectToCurrentSite(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")() + defer test.MockVariableValue(&setting.AppSubURL, "/sub")() + cases := []struct { + location string + want string + }{ + {"/", "/sub/"}, + {"http://localhost:3000/sub?k=v", "http://localhost:3000/sub?k=v"}, + {"http://other", "/sub/"}, + } + for _, c := range cases { + t.Run(c.location, func(t *testing.T) { + req := &http.Request{URL: &url.URL{Path: "/"}} + resp := httptest.NewRecorder() + base, baseCleanUp := NewBaseContext(resp, req) + defer baseCleanUp() + ctx := NewWebContext(base, nil, nil) + ctx.RedirectToCurrentSite(c.location) + redirect := test.RedirectURL(resp) + assert.Equal(t, c.want, redirect) + }) + } +} diff --git a/modules/context/csrf.go b/services/context/csrf.go similarity index 100% rename from modules/context/csrf.go rename to services/context/csrf.go diff --git a/modules/context/org.go b/services/context/org.go similarity index 85% rename from modules/context/org.go rename to services/context/org.go index 7638ffdd3f1..018b76de43a 100644 --- a/modules/context/org.go +++ b/services/context/org.go @@ -11,6 +11,8 @@ import ( "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -46,7 +48,7 @@ func GetOrganizationByParams(ctx *Context) { ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName) if err != nil { if organization.IsErrOrgNotExist(err) { - redirectUserID, err := user_model.LookupUserRedirect(orgName) + redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName) if err == nil { RedirectToUser(ctx.Base, orgName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { @@ -128,7 +130,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Org.IsTeamAdmin = true ctx.Org.CanCreateOrgRepo = true } else if ctx.IsSigned { - ctx.Org.IsOwner, err = org.IsOwnedBy(ctx.Doer.ID) + ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("IsOwnedBy", err) return @@ -140,12 +142,12 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Org.IsTeamAdmin = true ctx.Org.CanCreateOrgRepo = true } else { - ctx.Org.IsMember, err = org.IsOrgMember(ctx.Doer.ID) + ctx.Org.IsMember, err = org.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("IsOrgMember", err) return } - ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx.Doer.ID) + ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("CanCreateOrgRepo", err) return @@ -165,7 +167,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["IsPublicMember"] = func(uid int64) bool { - is, _ := organization.IsPublicMembership(ctx.Org.Organization.ID, uid) + is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid) return is } ctx.Data["CanCreateOrgRepo"] = ctx.Org.CanCreateOrgRepo @@ -179,7 +181,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { OrgID: org.ID, PublicOnly: ctx.Org.PublicMemberOnly, } - ctx.Data["NumMembers"], err = organization.CountOrgMembers(opts) + ctx.Data["NumMembers"], err = organization.CountOrgMembers(ctx, opts) if err != nil { ctx.ServerError("CountOrgMembers", err) return @@ -191,7 +193,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { if ctx.Org.IsOwner { shouldSeeAllTeams = true } else { - teams, err := org.GetUserTeams(ctx.Doer.ID) + teams, err := org.GetUserTeams(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetUserTeams", err) return @@ -204,13 +206,13 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { } } if shouldSeeAllTeams { - ctx.Org.Teams, err = org.LoadTeams() + ctx.Org.Teams, err = org.LoadTeams(ctx) if err != nil { ctx.ServerError("LoadTeams", err) return } } else { - ctx.Org.Teams, err = org.GetUserTeams(ctx.Doer.ID) + ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetUserTeams", err) return @@ -255,6 +257,19 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Data["CanReadProjects"] = ctx.Org.CanReadUnit(ctx, unit.TypeProjects) ctx.Data["CanReadPackages"] = ctx.Org.CanReadUnit(ctx, unit.TypePackages) ctx.Data["CanReadCode"] = ctx.Org.CanReadUnit(ctx, unit.TypeCode) + + ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + if len(ctx.ContextUser.Description) != 0 { + content, err := markdown.RenderString(&markup.RenderContext{ + Metas: map[string]string{"mode": "document"}, + Ctx: ctx, + }, ctx.ContextUser.Description) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + ctx.Data["RenderedDescription"] = content + } } // OrgAssignment returns a middleware to handle organization assignment diff --git a/modules/context/package.go b/services/context/package.go similarity index 97% rename from modules/context/package.go rename to services/context/package.go index c0813fb2da6..c452c657e78 100644 --- a/modules/context/package.go +++ b/services/context/package.go @@ -93,7 +93,7 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, string, any)) } func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) { - if setting.Service.RequireSignInView && doer == nil { + if setting.Service.RequireSignInView && (doer == nil || doer.IsGhost()) { return perm.AccessModeNone, nil } @@ -109,7 +109,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A if doer != nil && !doer.IsGhost() { // 1. If user is logged in, check all team packages permissions var err error - accessMode, err = org.GetOrgUserMaxAuthorizeLevel(doer.ID) + accessMode, err = org.GetOrgUserMaxAuthorizeLevel(ctx, doer.ID) if err != nil { return accessMode, err } diff --git a/modules/context/pagination.go b/services/context/pagination.go similarity index 70% rename from modules/context/pagination.go rename to services/context/pagination.go index 68237c630c0..fb2ef699ce4 100644 --- a/modules/context/pagination.go +++ b/services/context/pagination.go @@ -26,17 +26,6 @@ func NewPagination(total, pagingNum, current, numPages int) *Pagination { return p } -// AddParam adds a value from context identified by ctxKey as link param under a given paramKey -func (p *Pagination) AddParam(ctx *Context, paramKey, ctxKey string) { - _, exists := ctx.Data[ctxKey] - if !exists { - return - } - paramData := fmt.Sprintf("%v", ctx.Data[ctxKey]) // cast any to string - urlParam := fmt.Sprintf("%s=%v", url.QueryEscape(paramKey), url.QueryEscape(paramData)) - p.urlParams = append(p.urlParams, urlParam) -} - // AddParamString adds a string parameter directly func (p *Pagination) AddParamString(key, value string) { urlParam := fmt.Sprintf("%s=%v", url.QueryEscape(key), url.QueryEscape(value)) @@ -50,8 +39,14 @@ func (p *Pagination) GetParams() template.URL { // SetDefaultParams sets common pagination params that are often used func (p *Pagination) SetDefaultParams(ctx *Context) { - p.AddParam(ctx, "sort", "SortType") - p.AddParam(ctx, "q", "Keyword") + if v, ok := ctx.Data["SortType"].(string); ok { + p.AddParamString("sort", v) + } + if v, ok := ctx.Data["Keyword"].(string); ok { + p.AddParamString("q", v) + } + if v, ok := ctx.Data["IsFuzzy"].(bool); ok { + p.AddParamString("fuzzy", fmt.Sprint(v)) + } // do not add any more uncommon params here! - p.AddParam(ctx, "t", "queryType") } diff --git a/modules/context/permission.go b/services/context/permission.go similarity index 100% rename from modules/context/permission.go rename to services/context/permission.go diff --git a/modules/context/private.go b/services/context/private.go similarity index 100% rename from modules/context/private.go rename to services/context/private.go diff --git a/modules/context/repo.go b/services/context/repo.go similarity index 90% rename from modules/context/repo.go rename to services/context/repo.go index f9c966d5be9..56e9fada0e9 100644 --- a/modules/context/repo.go +++ b/services/context/repo.go @@ -6,6 +6,7 @@ package context import ( "context" + "errors" "fmt" "html" "net/http" @@ -23,8 +24,10 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" code_indexer "code.gitea.io/gitea/modules/indexer/code" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -80,11 +83,15 @@ func (r *Repository) CanCreateBranch() bool { return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch() } +func (r *Repository) GetObjectFormat() git.ObjectFormat { + return git.ObjectFormatFromName(r.Repository.ObjectFormatName) +} + // RepoMustNotBeArchived checks if a repo is archived func RepoMustNotBeArchived() func(ctx *Context) { return func(ctx *Context) { if ctx.Repo.Repository.IsArchived { - ctx.NotFound("IsArchived", fmt.Errorf(ctx.Tr("repo.archive.title"))) + ctx.NotFound("IsArchived", errors.New(ctx.Locale.TrString("repo.archive.title"))) } } } @@ -144,18 +151,18 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use } // CanUseTimetracker returns whether or not a user can use the timetracker. -func (r *Repository) CanUseTimetracker(issue *issues_model.Issue, user *user_model.User) bool { +func (r *Repository) CanUseTimetracker(ctx context.Context, issue *issues_model.Issue, user *user_model.User) bool { // Checking for following: // 1. Is timetracker enabled // 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this? - isAssigned, _ := issues_model.IsUserAssignedToIssue(db.DefaultContext, issue, user) - return r.Repository.IsTimetrackerEnabled(db.DefaultContext) && (!r.Repository.AllowOnlyContributorsToTrackTime(db.DefaultContext) || + isAssigned, _ := issues_model.IsUserAssignedToIssue(ctx, issue, user) + return r.Repository.IsTimetrackerEnabled(ctx) && (!r.Repository.AllowOnlyContributorsToTrackTime(ctx) || r.Permission.CanWriteIssuesOrPulls(issue.IsPull) || issue.IsPoster(user.ID) || isAssigned) } // CanCreateIssueDependencies returns whether or not a user can create dependencies. -func (r *Repository) CanCreateIssueDependencies(user *user_model.User, isPull bool) bool { - return r.Repository.IsDependenciesEnabled(db.DefaultContext) && r.Permission.CanWriteIssuesOrPulls(isPull) +func (r *Repository) CanCreateIssueDependencies(ctx context.Context, user *user_model.User, isPull bool) bool { + return r.Repository.IsDependenciesEnabled(ctx) && r.Permission.CanWriteIssuesOrPulls(isPull) } // GetCommitsCount returns cached commit count for current view @@ -401,26 +408,6 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) { ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty } -// RepoIDAssignment returns a handler which assigns the repo to the context. -func RepoIDAssignment() func(ctx *Context) { - return func(ctx *Context) { - repoID := ctx.ParamsInt64(":repoid") - - // Get repository. - repo, err := repo_model.GetRepositoryByID(ctx, repoID) - if err != nil { - if repo_model.IsErrRepoNotExist(err) { - ctx.NotFound("GetRepositoryByID", nil) - } else { - ctx.ServerError("GetRepositoryByID", err) - } - return - } - - repoAssignment(ctx, repo) - } -} - // RepoAssignment returns a middleware to handle repository assignment func RepoAssignment(ctx *Context) context.CancelFunc { if _, repoAssignmentOnce := ctx.Data["repoAssignmentExecuted"]; repoAssignmentOnce { @@ -456,7 +443,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { return nil } - if redirectUserID, err := user_model.LookupUserRedirect(userName); err == nil { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { RedirectToUser(ctx.Base, userName, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { ctx.NotFound("GetUserByName", nil) @@ -495,10 +482,10 @@ func RepoAssignment(ctx *Context) context.CancelFunc { } // Get repository. - repo, err := repo_model.GetRepositoryByName(owner.ID, repoName) + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) if err != nil { if repo_model.IsErrRepoNotExist(err) { - redirectRepoID, err := repo_model.LookupRedirect(owner.ID, repoName) + redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName) if err == nil { RedirectToRepo(ctx.Base, redirectRepoID) } else if repo_model.IsErrRedirectNotExist(err) { @@ -536,16 +523,21 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["RepoExternalIssuesLink"] = unit.ExternalTrackerConfig().ExternalTrackerURL } - ctx.Data["NumTags"], err = repo_model.GetReleaseCountByRepoID(ctx, ctx.Repo.Repository.ID, repo_model.FindReleasesOptions{ + ctx.Data["NumTags"], err = db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{ IncludeDrafts: true, IncludeTags: true, - HasSha1: util.OptionalBoolTrue, // only draft releases which are created with existing tags + HasSha1: optional.Some(true), // only draft releases which are created with existing tags + RepoID: ctx.Repo.Repository.ID, }) if err != nil { ctx.ServerError("GetReleaseCountByRepoID", err) return nil } - ctx.Data["NumReleases"], err = repo_model.GetReleaseCountByRepoID(ctx, ctx.Repo.Repository.ID, repo_model.FindReleasesOptions{}) + ctx.Data["NumReleases"], err = db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + // only show draft releases for users who can write, read-only users shouldn't see draft releases. + IncludeDrafts: ctx.Repo.CanWrite(unit_model.TypeReleases), + RepoID: ctx.Repo.Repository.ID, + }) if err != nil { ctx.ServerError("GetReleaseCountByRepoID", err) return nil @@ -560,6 +552,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["CanWriteCode"] = ctx.Repo.CanWrite(unit_model.TypeCode) ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues) ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests) + ctx.Data["CanWriteActions"] = ctx.Repo.CanWrite(unit_model.TypeActions) canSignedUserFork, err := repo_module.CanUserForkRepo(ctx, ctx.Doer, ctx.Repo.Repository) if err != nil { @@ -627,7 +620,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { return nil } - gitRepo, err := git.OpenRepository(ctx, repo_model.RepoPath(userName, repoName)) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "no such file or directory") { log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RepoPath(), err) @@ -639,7 +632,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { } return nil } - ctx.ServerError("RepoAssignment Invalid repo "+repo_model.RepoPath(userName, repoName), err) + ctx.ServerError("RepoAssignment Invalid repo "+repo.FullName(), err) return nil } if ctx.Repo.GitRepo != nil { @@ -663,12 +656,10 @@ func RepoAssignment(ctx *Context) context.CancelFunc { branchOpts := git_model.FindBranchOptions{ RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: util.OptionalBoolFalse, - ListOptions: db.ListOptions{ - ListAll: true, - }, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptionsAll, } - branchesTotal, err := git_model.CountBranches(ctx, branchOpts) + branchesTotal, err := db.Count[git_model.Branch](ctx, branchOpts) if err != nil { ctx.ServerError("CountBranches", err) return cancel @@ -690,7 +681,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { if len(ctx.Repo.Repository.DefaultBranch) > 0 && gitRepo.IsBranchExist(ctx.Repo.Repository.DefaultBranch) { ctx.Repo.BranchName = ctx.Repo.Repository.DefaultBranch } else { - ctx.Repo.BranchName, _ = gitRepo.GetDefaultBranch() + ctx.Repo.BranchName, _ = gitrepo.GetDefaultBranch(ctx, ctx.Repo.Repository) if ctx.Repo.BranchName == "" { // If it still can't get a default branch, fall back to default branch from setting. // Something might be wrong. Either site admin should fix the repo sync or Gitea should fix a potential bug. @@ -708,13 +699,13 @@ func RepoAssignment(ctx *Context) context.CancelFunc { // Pull request is allowed if this is a fork repository // and base repository accepts pull requests. - if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls() { + if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) { canCompare = true ctx.Data["BaseRepo"] = repo.BaseRepo ctx.Repo.PullRequest.BaseRepo = repo.BaseRepo ctx.Repo.PullRequest.Allowed = canPush ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Repo.Owner.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName) - } else if repo.AllowsPulls() { + } else if repo.AllowsPulls(ctx) { // Or, this is repository accepts pull requests between branches. canCompare = true ctx.Data["BaseRepo"] = repo @@ -740,7 +731,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["RepoTransfer"] = repoTransfer if ctx.Doer != nil { - ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx.Doer) + ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) } } @@ -773,6 +764,8 @@ const ( RepoRefBlob ) +const headRefName = "HEAD" + // RepoRef handles repository reference names when the ref name is not // explicitly given func RepoRef() func(*Context) context.CancelFunc { @@ -821,7 +814,8 @@ func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string { } // For legacy and API support only full commit sha parts := strings.Split(path, "/") - if len(parts) > 0 && len(parts[0]) == git.SHAFullLength { + + if len(parts) > 0 && len(parts[0]) == git.ObjectFormatFromName(repo.Repository.ObjectFormatName).FullLength() { repo.TreePath = strings.Join(parts[1:], "/") return parts[0] } @@ -833,11 +827,19 @@ func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string { case RepoRefBranch: ref := getRefNameFromPath(ctx, repo, path, repo.GitRepo.IsBranchExist) if len(ref) == 0 { + + // check if ref is HEAD + parts := strings.Split(path, "/") + if parts[0] == headRefName { + repo.TreePath = strings.Join(parts[1:], "/") + return repo.Repository.DefaultBranch + } + // maybe it's a renamed branch return getRefNameFromPath(ctx, repo, path, func(s string) bool { b, exist, err := git_model.FindRenamedBranch(ctx, repo.Repository.ID, s) if err != nil { - log.Error("FindRenamedBranch", err) + log.Error("FindRenamedBranch: %v", err) return false } @@ -857,10 +859,21 @@ func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string { return getRefNameFromPath(ctx, repo, path, repo.GitRepo.IsTagExist) case RepoRefCommit: parts := strings.Split(path, "/") - if len(parts) > 0 && len(parts[0]) >= 7 && len(parts[0]) <= git.SHAFullLength { + + if len(parts) > 0 && len(parts[0]) >= 7 && len(parts[0]) <= repo.GetObjectFormat().FullLength() { repo.TreePath = strings.Join(parts[1:], "/") return parts[0] } + + if len(parts) > 0 && parts[0] == headRefName { + // HEAD ref points to last default branch commit + commit, err := repo.GitRepo.GetBranchCommit(repo.Repository.DefaultBranch) + if err != nil { + return "" + } + repo.TreePath = strings.Join(parts[1:], "/") + return commit.ID.String() + } case RepoRefBlob: _, err := repo.GitRepo.GetBlob(path) if err != nil { @@ -892,10 +905,9 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context ) if ctx.Repo.GitRepo == nil { - repoPath := repo_model.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) - ctx.Repo.GitRepo, err = git.OpenRepository(ctx, repoPath) + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { - ctx.ServerError("RepoRef Invalid repo "+repoPath, err) + ctx.ServerError(fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err) return nil } // We opened it, we should close it @@ -973,7 +985,7 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context return cancel } ctx.Repo.CommitID = ctx.Repo.Commit.ID.String() - } else if len(refName) >= 7 && len(refName) <= git.SHAFullLength { + } else if len(refName) >= 7 && len(refName) <= ctx.Repo.GetObjectFormat().FullLength() { ctx.Repo.IsViewCommit = true ctx.Repo.CommitID = refName @@ -983,7 +995,7 @@ func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context return cancel } // If short commit ID add canonical link header - if len(refName) < git.SHAFullLength { + if len(refName) < ctx.Repo.GetObjectFormat().FullLength() { ctx.RespHeader().Set("Link", fmt.Sprintf("<%s>; rel=\"canonical\"", util.URLJoin(setting.AppURL, strings.Replace(ctx.Req.URL.RequestURI(), util.PathEscapeSegments(refName), url.PathEscape(ctx.Repo.Commit.ID.String()), 1)))) } diff --git a/modules/context/response.go b/services/context/response.go similarity index 100% rename from modules/context/response.go rename to services/context/response.go diff --git a/modules/upload/upload.go b/services/context/upload/upload.go similarity index 98% rename from modules/upload/upload.go rename to services/context/upload/upload.go index cd107158644..77a7eb93773 100644 --- a/modules/upload/upload.go +++ b/services/context/upload/upload.go @@ -11,9 +11,9 @@ import ( "regexp" "strings" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ) // ErrFileTypeForbidden not allowed file type error diff --git a/modules/upload/upload_test.go b/services/context/upload/upload_test.go similarity index 100% rename from modules/upload/upload_test.go rename to services/context/upload/upload_test.go diff --git a/services/context/user.go b/services/context/user.go index 81c2746819e..4c9cd2928b9 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -9,12 +9,11 @@ import ( "strings" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" ) // UserAssignmentWeb returns a middleware to handle context-user assignment for web routes -func UserAssignmentWeb() func(ctx *context.Context) { - return func(ctx *context.Context) { +func UserAssignmentWeb() func(ctx *Context) { + return func(ctx *Context) { errorFn := func(status int, title string, obj any) { err, ok := obj.(error) if !ok { @@ -32,8 +31,8 @@ func UserAssignmentWeb() func(ctx *context.Context) { } // UserIDAssignmentAPI returns a middleware to handle context-user assignment for api routes -func UserIDAssignmentAPI() func(ctx *context.APIContext) { - return func(ctx *context.APIContext) { +func UserIDAssignmentAPI() func(ctx *APIContext) { + return func(ctx *APIContext) { userID := ctx.ParamsInt64(":user-id") if ctx.IsSigned && ctx.Doer.ID == userID { @@ -53,13 +52,13 @@ func UserIDAssignmentAPI() func(ctx *context.APIContext) { } // UserAssignmentAPI returns a middleware to handle context-user assignment for api routes -func UserAssignmentAPI() func(ctx *context.APIContext) { - return func(ctx *context.APIContext) { +func UserAssignmentAPI() func(ctx *APIContext) { + return func(ctx *APIContext) { ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, ctx.Error) } } -func userAssignment(ctx *context.Base, doer *user_model.User, errCb func(int, string, any)) (contextUser *user_model.User) { +func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, string, any)) (contextUser *user_model.User) { username := ctx.Params(":username") if doer != nil && doer.LowerName == strings.ToLower(username) { @@ -69,8 +68,8 @@ func userAssignment(ctx *context.Base, doer *user_model.User, errCb func(int, st contextUser, err = user_model.GetUserByName(ctx, username) if err != nil { if user_model.IsErrUserNotExist(err) { - if redirectUserID, err := user_model.LookupUserRedirect(username); err == nil { - context.RedirectToUser(ctx, username, redirectUserID) + if redirectUserID, err := user_model.LookupUserRedirect(ctx, username); err == nil { + RedirectToUser(ctx, username, redirectUserID) } else if user_model.IsErrUserRedirectNotExist(err) { errCb(http.StatusNotFound, "GetUserByName", err) } else { diff --git a/modules/context/utils.go b/services/context/utils.go similarity index 100% rename from modules/context/utils.go rename to services/context/utils.go diff --git a/modules/context/xsrf.go b/services/context/xsrf.go similarity index 100% rename from modules/context/xsrf.go rename to services/context/xsrf.go diff --git a/modules/context/xsrf_test.go b/services/context/xsrf_test.go similarity index 100% rename from modules/context/xsrf_test.go rename to services/context/xsrf_test.go diff --git a/modules/contexttest/context_tests.go b/services/contexttest/context_tests.go similarity index 79% rename from modules/contexttest/context_tests.go rename to services/contexttest/context_tests.go index ea91bc50013..3064c56590d 100644 --- a/modules/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -7,21 +7,23 @@ package contexttest import ( gocontext "context" "io" + "maps" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" @@ -35,13 +37,24 @@ func mockRequest(t *testing.T, reqPath string) *http.Request { } requestURL, err := url.Parse(path) assert.NoError(t, err) - req := &http.Request{Method: method, URL: requestURL, Form: url.Values{}} + req := &http.Request{Method: method, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}} req = req.WithContext(middleware.WithContextData(req.Context())) return req } +type MockContextOption struct { + Render context.Render +} + // MockContext mock context for unit tests -func MockContext(t *testing.T, reqPath string) (*context.Context, *httptest.ResponseRecorder) { +func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*context.Context, *httptest.ResponseRecorder) { + var opt MockContextOption + if len(opts) > 0 { + opt = opts[0] + } + if opt.Render == nil { + opt.Render = &MockRender{} + } resp := httptest.NewRecorder() req := mockRequest(t, reqPath) base, baseCleanUp := context.NewBaseContext(resp, req) @@ -49,8 +62,10 @@ func MockContext(t *testing.T, reqPath string) (*context.Context, *httptest.Resp base.Data = middleware.GetContextData(req.Context()) base.Locale = &translation.MockLocale{} - ctx := context.NewWebContext(base, &MockRender{}, nil) - + ctx := context.NewWebContext(base, opt.Render, nil) + ctx.AppendContextValue(context.WebContextKey, ctx) + ctx.PageData = map[string]any{} + ctx.Data["PageStartTime"] = time.Now() chiCtx := chi.NewRouteContext() ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx) return ctx, resp @@ -83,8 +98,7 @@ func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) { ctx.Repo = repo doer = ctx.Doer default: - assert.Fail(t, "context is not *context.Context or *context.APIContext") - return + assert.FailNow(t, "context is not *context.Context or *context.APIContext") } repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) @@ -105,11 +119,10 @@ func LoadRepoCommit(t *testing.T, ctx gocontext.Context) { case *context.APIContext: repo = ctx.Repo default: - assert.Fail(t, "context is not *context.Context or *context.APIContext") - return + assert.FailNow(t, "context is not *context.Context or *context.APIContext") } - gitRepo, err := git.OpenRepository(ctx, repo.Repository.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo.Repository) assert.NoError(t, err) defer gitRepo.Close() branch, err := gitRepo.GetHEADBranch() @@ -130,8 +143,7 @@ func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) { case *context.APIContext: ctx.Doer = doer default: - assert.Fail(t, "context is not *context.Context or *context.APIContext") - return + assert.FailNow(t, "context is not *context.Context or *context.APIContext") } } @@ -140,7 +152,7 @@ func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) { func LoadGitRepo(t *testing.T, ctx *context.Context) { assert.NoError(t, ctx.Repo.Repository.LoadOwner(ctx)) var err error - ctx.Repo.GitRepo, err = git.OpenRepository(ctx, ctx.Repo.Repository.RepoPath()) + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) assert.NoError(t, err) } diff --git a/services/convert/attachment.go b/services/convert/attachment.go index ab36a1c5778..4a8f10f7b02 100644 --- a/services/convert/attachment.go +++ b/services/convert/attachment.go @@ -4,10 +4,7 @@ package convert import ( - "strconv" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" ) @@ -16,12 +13,7 @@ func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachm } func APIAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string { - if attach.CustomDownloadURL != "" { - return attach.CustomDownloadURL - } - - // /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} - return setting.AppURL + "api/repos/" + repo.FullName() + "/releases/" + strconv.FormatInt(attach.ReleaseID, 10) + "/assets/" + strconv.FormatInt(attach.ID, 10) + return attach.DownloadURL() } // ToAttachment converts models.Attachment to api.Attachment for API usage diff --git a/services/convert/convert.go b/services/convert/convert.go index a87352f51d1..ca3ec32a404 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -13,7 +13,6 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -120,15 +119,15 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch) *api if err != nil { log.Error("GetUserNamesByIDs (ApprovalsWhitelistUserIDs): %v", err) } - pushWhitelistTeams, err := organization.GetTeamNamesByID(bp.WhitelistTeamIDs) + pushWhitelistTeams, err := organization.GetTeamNamesByID(ctx, bp.WhitelistTeamIDs) if err != nil { log.Error("GetTeamNamesByID (WhitelistTeamIDs): %v", err) } - mergeWhitelistTeams, err := organization.GetTeamNamesByID(bp.MergeWhitelistTeamIDs) + mergeWhitelistTeams, err := organization.GetTeamNamesByID(ctx, bp.MergeWhitelistTeamIDs) if err != nil { log.Error("GetTeamNamesByID (MergeWhitelistTeamIDs): %v", err) } - approvalsWhitelistTeams, err := organization.GetTeamNamesByID(bp.ApprovalsWhitelistTeamIDs) + approvalsWhitelistTeams, err := organization.GetTeamNamesByID(ctx, bp.ApprovalsWhitelistTeamIDs) if err != nil { log.Error("GetTeamNamesByID (ApprovalsWhitelistTeamIDs): %v", err) } @@ -159,6 +158,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch) *api BlockOnOfficialReviewRequests: bp.BlockOnOfficialReviewRequests, BlockOnOutdatedBranch: bp.BlockOnOutdatedBranch, DismissStaleApprovals: bp.DismissStaleApprovals, + IgnoreStaleApprovals: bp.IgnoreStaleApprovals, RequireSignedCommits: bp.RequireSignedCommits, ProtectedFilePatterns: bp.ProtectedFilePatterns, UnprotectedFilePatterns: bp.UnprotectedFilePatterns, @@ -309,40 +309,38 @@ func ToTeam(ctx context.Context, team *organization.Team, loadOrg ...bool) (*api // ToTeams convert models.Team list to api.Team list func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]*api.Team, error) { - if len(teams) == 0 || teams[0] == nil { - return nil, nil - } - cache := make(map[int64]*api.Organization) - apiTeams := make([]*api.Team, len(teams)) - for i := range teams { - if err := teams[i].LoadUnits(ctx); err != nil { + apiTeams := make([]*api.Team, 0, len(teams)) + for _, t := range teams { + if err := t.LoadUnits(ctx); err != nil { return nil, err } - apiTeams[i] = &api.Team{ - ID: teams[i].ID, - Name: teams[i].Name, - Description: teams[i].Description, - IncludesAllRepositories: teams[i].IncludesAllRepositories, - CanCreateOrgRepo: teams[i].CanCreateOrgRepo, - Permission: teams[i].AccessMode.String(), - Units: teams[i].GetUnitNames(), - UnitsMap: teams[i].GetUnitsMap(), + apiTeam := &api.Team{ + ID: t.ID, + Name: t.Name, + Description: t.Description, + IncludesAllRepositories: t.IncludesAllRepositories, + CanCreateOrgRepo: t.CanCreateOrgRepo, + Permission: t.AccessMode.String(), + Units: t.GetUnitNames(), + UnitsMap: t.GetUnitsMap(), } if loadOrgs { - apiOrg, ok := cache[teams[i].OrgID] + apiOrg, ok := cache[t.OrgID] if !ok { - org, err := organization.GetOrgByID(db.DefaultContext, teams[i].OrgID) + org, err := organization.GetOrgByID(ctx, t.OrgID) if err != nil { return nil, err } apiOrg = ToOrganization(ctx, org) - cache[teams[i].OrgID] = apiOrg + cache[t.OrgID] = apiOrg } - apiTeams[i].Organization = apiOrg + apiTeam.Organization = apiOrg } + + apiTeams = append(apiTeams, apiTeam) } return apiTeams, nil } diff --git a/services/convert/git_commit.go b/services/convert/git_commit.go index ac15719c1cf..e0efcddbcb0 100644 --- a/services/convert/git_commit.go +++ b/services/convert/git_commit.go @@ -10,11 +10,11 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - ctx "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + ctx "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/gitdiff" ) @@ -210,7 +210,7 @@ func ToCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Rep // Get diff stats for commit if opts.Stat { - diff, err := gitdiff.GetDiff(gitRepo, &gitdiff.DiffOptions{ + diff, err := gitdiff.GetDiff(ctx, gitRepo, &gitdiff.DiffOptions{ AfterCommitID: commit.ID.String(), }) if err != nil { diff --git a/services/convert/git_commit_test.go b/services/convert/git_commit_test.go index 8c4ef88ebe0..73cb5e8c717 100644 --- a/services/convert/git_commit_test.go +++ b/services/convert/git_commit_test.go @@ -19,12 +19,12 @@ import ( func TestToCommitMeta(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - sha1, _ := git.NewIDFromString("0000000000000000000000000000000000000000") + sha1 := git.Sha1ObjectFormat signature := &git.Signature{Name: "Test Signature", Email: "test@email.com", When: time.Unix(0, 0)} tag := &git.Tag{ Name: "Test Tag", - ID: sha1, - Object: sha1, + ID: sha1.EmptyObjectID(), + Object: sha1.EmptyObjectID(), Type: "Test Type", Tagger: signature, Message: "Test Message", @@ -34,8 +34,8 @@ func TestToCommitMeta(t *testing.T) { assert.NotNil(t, commitMeta) assert.EqualValues(t, &api.CommitMeta{ - SHA: "0000000000000000000000000000000000000000", - URL: util.URLJoin(headRepo.APIURL(), "git/commits", "0000000000000000000000000000000000000000"), + SHA: sha1.EmptyObjectID().String(), + URL: util.URLJoin(headRepo.APIURL(), "git/commits", sha1.EmptyObjectID().String()), Created: time.Unix(0, 0), }, commitMeta) } diff --git a/services/convert/issue.go b/services/convert/issue.go index 33fad31d48a..54b00cd88ea 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -9,7 +9,6 @@ import ( "net/url" "strings" - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -19,19 +18,19 @@ import ( api "code.gitea.io/gitea/modules/structs" ) -func ToIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { - return toIssue(ctx, issue, WebAssetDownloadURL) +func ToIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) *api.Issue { + return toIssue(ctx, doer, issue, WebAssetDownloadURL) } // ToAPIIssue converts an Issue to API format // it assumes some fields assigned with values: // Required - Poster, Labels, // Optional - Milestone, Assignee, PullRequest -func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue { - return toIssue(ctx, issue, APIAssetDownloadURL) +func ToAPIIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) *api.Issue { + return toIssue(ctx, doer, issue, APIAssetDownloadURL) } -func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Issue { +func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Issue { if err := issue.LoadLabels(ctx); err != nil { return &api.Issue{} } @@ -45,7 +44,7 @@ func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func apiIssue := &api.Issue{ ID: issue.ID, Index: issue.Index, - Poster: ToUser(ctx, issue.Poster, nil), + Poster: ToUser(ctx, issue.Poster, doer), Title: issue.Title, Body: issue.Content, Attachments: toAttachments(issue.Repo, issue.Attachments, getDownloadURL), @@ -62,7 +61,7 @@ func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func if err := issue.Repo.LoadOwner(ctx); err != nil { return &api.Issue{} } - apiIssue.URL = issue.APIURL() + apiIssue.URL = issue.APIURL(ctx) apiIssue.HTMLURL = issue.HTMLURL() apiIssue.Labels = ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner) apiIssue.Repo = &api.RepositoryMeta{ @@ -99,7 +98,8 @@ func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func } if issue.PullRequest != nil { apiIssue.PullRequest = &api.PullRequestMeta{ - HasMerged: issue.PullRequest.HasMerged, + HasMerged: issue.PullRequest.HasMerged, + IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx), } if issue.PullRequest.HasMerged { apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() @@ -114,25 +114,25 @@ func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func } // ToIssueList converts an IssueList to API format -func ToIssueList(ctx context.Context, il issues_model.IssueList) []*api.Issue { +func ToIssueList(ctx context.Context, doer *user_model.User, il issues_model.IssueList) []*api.Issue { result := make([]*api.Issue, len(il)) for i := range il { - result[i] = ToIssue(ctx, il[i]) + result[i] = ToIssue(ctx, doer, il[i]) } return result } // ToAPIIssueList converts an IssueList to API format -func ToAPIIssueList(ctx context.Context, il issues_model.IssueList) []*api.Issue { +func ToAPIIssueList(ctx context.Context, doer *user_model.User, il issues_model.IssueList) []*api.Issue { result := make([]*api.Issue, len(il)) for i := range il { - result[i] = ToAPIIssue(ctx, il[i]) + result[i] = ToAPIIssue(ctx, doer, il[i]) } return result } // ToTrackedTime converts TrackedTime to API format -func ToTrackedTime(ctx context.Context, t *issues_model.TrackedTime) (apiT *api.TrackedTime) { +func ToTrackedTime(ctx context.Context, doer *user_model.User, t *issues_model.TrackedTime) (apiT *api.TrackedTime) { apiT = &api.TrackedTime{ ID: t.ID, IssueID: t.IssueID, @@ -141,7 +141,7 @@ func ToTrackedTime(ctx context.Context, t *issues_model.TrackedTime) (apiT *api. Created: t.Created, } if t.Issue != nil { - apiT.Issue = ToAPIIssue(ctx, t.Issue) + apiT.Issue = ToAPIIssue(ctx, doer, t.Issue) } if t.User != nil { apiT.UserName = t.User.Name @@ -150,7 +150,7 @@ func ToTrackedTime(ctx context.Context, t *issues_model.TrackedTime) (apiT *api. } // ToStopWatches convert Stopwatch list to api.StopWatches -func ToStopWatches(sws []*issues_model.Stopwatch) (api.StopWatches, error) { +func ToStopWatches(ctx context.Context, sws []*issues_model.Stopwatch) (api.StopWatches, error) { result := api.StopWatches(make([]api.StopWatch, 0, len(sws))) issueCache := make(map[int64]*issues_model.Issue) @@ -165,14 +165,14 @@ func ToStopWatches(sws []*issues_model.Stopwatch) (api.StopWatches, error) { for _, sw := range sws { issue, ok = issueCache[sw.IssueID] if !ok { - issue, err = issues_model.GetIssueByID(db.DefaultContext, sw.IssueID) + issue, err = issues_model.GetIssueByID(ctx, sw.IssueID) if err != nil { return nil, err } } repo, ok = repoCache[issue.RepoID] if !ok { - repo, err = repo_model.GetRepositoryByID(db.DefaultContext, issue.RepoID) + repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID) if err != nil { return nil, err } @@ -192,10 +192,10 @@ func ToStopWatches(sws []*issues_model.Stopwatch) (api.StopWatches, error) { } // ToTrackedTimeList converts TrackedTimeList to API format -func ToTrackedTimeList(ctx context.Context, tl issues_model.TrackedTimeList) api.TrackedTimeList { +func ToTrackedTimeList(ctx context.Context, doer *user_model.User, tl issues_model.TrackedTimeList) api.TrackedTimeList { result := make([]*api.TrackedTime, 0, len(tl)) for _, t := range tl { - result = append(result, ToTrackedTime(ctx, t)) + result = append(result, ToTrackedTime(ctx, doer, t)) } return result } diff --git a/services/convert/issue_comment.go b/services/convert/issue_comment.go index 1308051e7c1..9ffaf1e84c7 100644 --- a/services/convert/issue_comment.go +++ b/services/convert/issue_comment.go @@ -19,9 +19,9 @@ func ToAPIComment(ctx context.Context, repo *repo_model.Repository, c *issues_mo return &api.Comment{ ID: c.ID, Poster: ToUser(ctx, c.Poster, nil), - HTMLURL: c.HTMLURL(), - IssueURL: c.IssueURL(), - PRURL: c.PRURL(), + HTMLURL: c.HTMLURL(ctx), + IssueURL: c.IssueURL(ctx), + PRURL: c.PRURL(ctx), Body: c.Content, Attachments: ToAPIAttachments(repo, c.Attachments), Created: c.CreatedUnix.AsTime(), @@ -37,31 +37,31 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu return nil } - err = c.LoadAssigneeUserAndTeam() + err = c.LoadAssigneeUserAndTeam(ctx) if err != nil { log.Error("LoadAssigneeUserAndTeam: %v", err) return nil } - err = c.LoadResolveDoer() + err = c.LoadResolveDoer(ctx) if err != nil { log.Error("LoadResolveDoer: %v", err) return nil } - err = c.LoadDepIssueDetails() + err = c.LoadDepIssueDetails(ctx) if err != nil { log.Error("LoadDepIssueDetails: %v", err) return nil } - err = c.LoadTime() + err = c.LoadTime(ctx) if err != nil { log.Error("LoadTime: %v", err) return nil } - err = c.LoadLabel() + err = c.LoadLabel(ctx) if err != nil { log.Error("LoadLabel: %v", err) return nil @@ -82,9 +82,9 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu ID: c.ID, Type: c.Type.String(), Poster: ToUser(ctx, c.Poster, nil), - HTMLURL: c.HTMLURL(), - IssueURL: c.IssueURL(), - PRURL: c.PRURL(), + HTMLURL: c.HTMLURL(ctx), + IssueURL: c.IssueURL(ctx), + PRURL: c.PRURL(ctx), Body: c.Content, Created: c.CreatedUnix.AsTime(), Updated: c.UpdatedUnix.AsTime(), @@ -120,7 +120,7 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu return nil } - comment.TrackedTime = ToTrackedTime(ctx, c.Time) + comment.TrackedTime = ToTrackedTime(ctx, doer, c.Time) } if c.RefIssueID != 0 { @@ -129,7 +129,7 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu log.Error("GetIssueByID(%d): %v", c.RefIssueID, err) return nil } - comment.RefIssue = ToAPIIssue(ctx, issue) + comment.RefIssue = ToAPIIssue(ctx, doer, issue) } if c.RefCommentID != 0 { @@ -180,7 +180,7 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu } if c.DependentIssue != nil { - comment.DependentIssue = ToAPIIssue(ctx, c.DependentIssue) + comment.DependentIssue = ToAPIIssue(ctx, doer, c.DependentIssue) } return comment diff --git a/services/convert/main_test.go b/services/convert/main_test.go index c2298dcb74a..363cc4a97f5 100644 --- a/services/convert/main_test.go +++ b/services/convert/main_test.go @@ -4,7 +4,6 @@ package convert import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -13,7 +12,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/convert/mirror.go b/services/convert/mirror.go index f7a8e17fd0c..249ce2f9689 100644 --- a/services/convert/mirror.go +++ b/services/convert/mirror.go @@ -4,36 +4,23 @@ package convert import ( + "context" + repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" ) // ToPushMirror convert from repo_model.PushMirror and remoteAddress to api.TopicResponse -func ToPushMirror(pm *repo_model.PushMirror) (*api.PushMirror, error) { - repo := pm.GetRepository() - remoteAddress, err := getRemoteAddress(repo, pm.RemoteName) - if err != nil { - return nil, err - } +func ToPushMirror(ctx context.Context, pm *repo_model.PushMirror) (*api.PushMirror, error) { + repo := pm.GetRepository(ctx) return &api.PushMirror{ RepoName: repo.Name, RemoteName: pm.RemoteName, - RemoteAddress: remoteAddress, - CreatedUnix: pm.CreatedUnix.FormatLong(), - LastUpdateUnix: pm.LastUpdateUnix.FormatLong(), + RemoteAddress: pm.RemoteAddress, + CreatedUnix: pm.CreatedUnix.AsTime(), + LastUpdateUnix: pm.LastUpdateUnix.AsTimePtr(), LastError: pm.LastError, Interval: pm.Interval.String(), SyncOnCommit: pm.SyncOnCommit, }, nil } - -func getRemoteAddress(repo *repo_model.Repository, remoteName string) (string, error) { - url, err := git.GetRemoteURL(git.DefaultContext, repo.RepoPath(), remoteName) - if err != nil { - return "", err - } - // remove confidential information - url.User = nil - return url.String(), nil -} diff --git a/services/convert/notification.go b/services/convert/notification.go index 3906fa9b388..41063cf399f 100644 --- a/services/convert/notification.go +++ b/services/convert/notification.go @@ -4,17 +4,17 @@ package convert import ( + "context" "net/url" activities_model "code.gitea.io/gitea/models/activities" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" api "code.gitea.io/gitea/modules/structs" ) // ToNotificationThread convert a Notification to api.NotificationThread -func ToNotificationThread(n *activities_model.Notification) *api.NotificationThread { +func ToNotificationThread(ctx context.Context, n *activities_model.Notification) *api.NotificationThread { result := &api.NotificationThread{ ID: n.ID, Unread: !(n.Status == activities_model.NotificationStatusRead || n.Status == activities_model.NotificationStatusPinned), @@ -25,7 +25,7 @@ func ToNotificationThread(n *activities_model.Notification) *api.NotificationThr // since user only get notifications when he has access to use minimal access mode if n.Repository != nil { - result.Repository = ToRepo(db.DefaultContext, n.Repository, access_model.Permission{AccessMode: perm.AccessModeRead}) + result.Repository = ToRepo(ctx, n.Repository, access_model.Permission{AccessMode: perm.AccessModeRead}) // This permission is not correct and we should not be reporting it for repository := result.Repository; repository != nil; repository = repository.Parent { @@ -39,30 +39,31 @@ func ToNotificationThread(n *activities_model.Notification) *api.NotificationThr result.Subject = &api.NotificationSubject{Type: api.NotifySubjectIssue} if n.Issue != nil { result.Subject.Title = n.Issue.Title - result.Subject.URL = n.Issue.APIURL() + result.Subject.URL = n.Issue.APIURL(ctx) result.Subject.HTMLURL = n.Issue.HTMLURL() result.Subject.State = n.Issue.State() - comment, err := n.Issue.GetLastComment() + comment, err := n.Issue.GetLastComment(ctx) if err == nil && comment != nil { - result.Subject.LatestCommentURL = comment.APIURL() - result.Subject.LatestCommentHTMLURL = comment.HTMLURL() + result.Subject.LatestCommentURL = comment.APIURL(ctx) + result.Subject.LatestCommentHTMLURL = comment.HTMLURL(ctx) } } case activities_model.NotificationSourcePullRequest: result.Subject = &api.NotificationSubject{Type: api.NotifySubjectPull} if n.Issue != nil { result.Subject.Title = n.Issue.Title - result.Subject.URL = n.Issue.APIURL() + result.Subject.URL = n.Issue.APIURL(ctx) result.Subject.HTMLURL = n.Issue.HTMLURL() result.Subject.State = n.Issue.State() - comment, err := n.Issue.GetLastComment() + comment, err := n.Issue.GetLastComment(ctx) if err == nil && comment != nil { - result.Subject.LatestCommentURL = comment.APIURL() - result.Subject.LatestCommentHTMLURL = comment.HTMLURL() + result.Subject.LatestCommentURL = comment.APIURL(ctx) + result.Subject.LatestCommentHTMLURL = comment.HTMLURL(ctx) } - pr, _ := n.Issue.GetPullRequest() - if pr != nil && pr.HasMerged { + if err := n.Issue.LoadPullRequest(ctx); err == nil && + n.Issue.PullRequest != nil && + n.Issue.PullRequest.HasMerged { result.Subject.State = "merged" } } @@ -88,10 +89,10 @@ func ToNotificationThread(n *activities_model.Notification) *api.NotificationThr } // ToNotifications convert list of Notification to api.NotificationThread list -func ToNotifications(nl activities_model.NotificationList) []*api.NotificationThread { +func ToNotifications(ctx context.Context, nl activities_model.NotificationList) []*api.NotificationThread { result := make([]*api.NotificationThread, 0, len(nl)) for _, n := range nl { - result = append(result, ToNotificationThread(n)) + result = append(result, ToNotificationThread(ctx, n)) } return result } diff --git a/services/convert/package.go b/services/convert/package.go index 276856594bb..b5fca21a3c3 100644 --- a/services/convert/package.go +++ b/services/convert/package.go @@ -35,6 +35,7 @@ func ToPackage(ctx context.Context, pd *packages.PackageDescriptor, doer *user_m Name: pd.Package.Name, Version: pd.Version.Version, CreatedAt: pd.Version.CreatedUnix.AsTime(), + HTMLURL: pd.VersionHTMLURL(), }, nil } diff --git a/services/convert/pull.go b/services/convert/pull.go index e4e3097056a..775bf3806d3 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -12,6 +12,7 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" ) @@ -32,7 +33,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u return nil } - apiIssue := ToAPIIssue(ctx, pr.Issue) + apiIssue := ToAPIIssue(ctx, doer, pr.Issue) if err := pr.LoadBaseRepo(ctx); err != nil { log.Error("GetRepositoryById[%d]: %v", pr.ID, err) return nil @@ -68,7 +69,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u PatchURL: pr.Issue.PatchURL(), HasMerged: pr.HasMerged, MergeBase: pr.MergeBase, - Mergeable: pr.Mergeable(), + Mergeable: pr.Mergeable(ctx), Deadline: apiIssue.Deadline, Created: pr.Issue.CreatedUnix.AsTimePtr(), Updated: pr.Issue.UpdatedUnix.AsTimePtr(), @@ -101,7 +102,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u apiPullRequest.Closed = pr.Issue.ClosedUnix.AsTimePtr() } - gitRepo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RepoPath(), err) return nil @@ -127,7 +128,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u } if pr.Flow == issues_model.PullRequestFlowAGit { - gitRepo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { log.Error("OpenRepository[%s]: %v", pr.GetGitRefName(), err) return nil @@ -154,7 +155,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u apiPullRequest.Head.RepoID = pr.HeadRepo.ID apiPullRequest.Head.Repository = ToRepo(ctx, pr.HeadRepo, p) - headGitRepo, err := git.OpenRepository(ctx, pr.HeadRepo.RepoPath()) + headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo) if err != nil { log.Error("OpenRepository[%s]: %v", pr.HeadRepo.RepoPath(), err) return nil @@ -190,7 +191,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u } if len(apiPullRequest.Head.Sha) == 0 && len(apiPullRequest.Head.Ref) != 0 { - baseGitRepo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RepoPath(), err) return nil diff --git a/services/convert/pull_review.go b/services/convert/pull_review.go index 5d5d5d883c9..29a5ab7466b 100644 --- a/services/convert/pull_review.go +++ b/services/convert/pull_review.go @@ -21,28 +21,30 @@ func ToPullReview(ctx context.Context, r *issues_model.Review, doer *user_model. r.Reviewer = user_model.NewGhostUser() } - apiTeam, err := ToTeam(ctx, r.ReviewerTeam) - if err != nil { - return nil, err - } - result := &api.PullReview{ ID: r.ID, Reviewer: ToUser(ctx, r.Reviewer, doer), - ReviewerTeam: apiTeam, State: api.ReviewStateUnknown, Body: r.Content, CommitID: r.CommitID, Stale: r.Stale, Official: r.Official, Dismissed: r.Dismissed, - CodeCommentsCount: r.GetCodeCommentsCount(), + CodeCommentsCount: r.GetCodeCommentsCount(ctx), Submitted: r.CreatedUnix.AsTime(), Updated: r.UpdatedUnix.AsTime(), - HTMLURL: r.HTMLURL(), + HTMLURL: r.HTMLURL(ctx), HTMLPullURL: r.Issue.HTMLURL(), } + if r.ReviewerTeam != nil { + var err error + result.ReviewerTeam, err = ToTeam(ctx, r.ReviewerTeam) + if err != nil { + return nil, err + } + } + switch r.Type { case issues_model.ReviewTypeApprove: result.State = api.ReviewStateApproved @@ -64,7 +66,7 @@ func ToPullReviewList(ctx context.Context, rl []*issues_model.Review, doer *user result := make([]*api.PullReview, 0, len(rl)) for i := range rl { // show pending reviews only for the user who created them - if rl[i].Type == issues_model.ReviewTypePending && !(doer.IsAdmin || doer.ID == rl[i].ReviewerID) { + if rl[i].Type == issues_model.ReviewTypePending && (doer == nil || (!doer.IsAdmin && doer.ID != rl[i].ReviewerID)) { continue } r, err := ToPullReview(ctx, rl[i], doer) @@ -102,7 +104,7 @@ func ToPullReviewCommentList(ctx context.Context, review *issues_model.Review, d CommitID: comment.CommitSHA, OrigCommitID: comment.OldRef, DiffHunk: patch2diff(comment.Patch), - HTMLURL: comment.HTMLURL(), + HTMLURL: comment.HTMLURL(ctx), HTMLPullURL: review.Issue.HTMLURL(), } diff --git a/services/convert/pull_review_test.go b/services/convert/pull_review_test.go new file mode 100644 index 00000000000..68869502802 --- /dev/null +++ b/services/convert/pull_review_test.go @@ -0,0 +1,52 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func Test_ToPullReview(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 6}) + assert.EqualValues(t, reviewer.ID, review.ReviewerID) + assert.EqualValues(t, issues_model.ReviewTypePending, review.Type) + + reviewList := []*issues_model.Review{review} + + t.Run("Anonymous User", func(t *testing.T) { + prList, err := ToPullReviewList(db.DefaultContext, reviewList, nil) + assert.NoError(t, err) + assert.Empty(t, prList) + }) + + t.Run("Reviewer Himself", func(t *testing.T) { + prList, err := ToPullReviewList(db.DefaultContext, reviewList, reviewer) + assert.NoError(t, err) + assert.Len(t, prList, 1) + }) + + t.Run("Other User", func(t *testing.T) { + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + prList, err := ToPullReviewList(db.DefaultContext, reviewList, user4) + assert.NoError(t, err) + assert.Len(t, prList, 0) + }) + + t.Run("Admin User", func(t *testing.T) { + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + prList, err := ToPullReviewList(db.DefaultContext, reviewList, adminUser) + assert.NoError(t, err) + assert.Len(t, prList, 1) + }) +} diff --git a/services/convert/repository.go b/services/convert/repository.go index 6f77b4932e4..39efd304a96 100644 --- a/services/convert/repository.go +++ b/services/convert/repository.go @@ -8,6 +8,7 @@ import ( "time" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" @@ -92,6 +93,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR allowRebase := false allowRebaseMerge := false allowSquash := false + allowFastForwardOnly := false allowRebaseUpdate := false defaultDeleteBranchAfterMerge := false defaultMergeStyle := repo_model.MergeStyleMerge @@ -104,14 +106,18 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR allowRebase = config.AllowRebase allowRebaseMerge = config.AllowRebaseMerge allowSquash = config.AllowSquash + allowFastForwardOnly = config.AllowFastForwardOnly allowRebaseUpdate = config.AllowRebaseUpdate defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge defaultMergeStyle = config.GetDefaultMergeStyle() defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit } hasProjects := false - if _, err := repo.GetUnit(ctx, unit_model.TypeProjects); err == nil { + projectsMode := repo_model.ProjectsModeAll + if unit, err := repo.GetUnit(ctx, unit_model.TypeProjects); err == nil { hasProjects = true + config := unit.ProjectsConfig() + projectsMode = config.ProjectsMode } hasReleases := false @@ -133,7 +139,11 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR return nil } - numReleases, _ := repo_model.GetReleaseCountByRepoID(ctx, repo.ID, repo_model.FindReleasesOptions{IncludeDrafts: false, IncludeTags: false}) + numReleases, _ := db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + IncludeDrafts: false, + IncludeTags: false, + RepoID: repo.ID, + }) mirrorInterval := "" var mirrorUpdated time.Time @@ -181,6 +191,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR Parent: parent, Mirror: repo.IsMirror, HTMLURL: repo.HTMLURL(), + URL: repoAPIURL, SSHURL: cloneLink.SSH, CloneURL: cloneLink.HTTPS, OriginalURL: repo.SanitizedOriginalURL(), @@ -203,6 +214,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR InternalTracker: internalTracker, HasWiki: hasWiki, HasProjects: hasProjects, + ProjectsMode: string(projectsMode), HasReleases: hasReleases, HasPackages: hasPackages, HasActions: hasActions, @@ -213,6 +225,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR AllowRebase: allowRebase, AllowRebaseMerge: allowRebaseMerge, AllowSquash: allowSquash, + AllowFastForwardOnly: allowFastForwardOnly, AllowRebaseUpdate: allowRebaseUpdate, DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge, DefaultMergeStyle: string(defaultMergeStyle), diff --git a/services/convert/wiki.go b/services/convert/wiki.go index 1f048434832..767bfdb88d0 100644 --- a/services/convert/wiki.go +++ b/services/convert/wiki.go @@ -6,11 +6,8 @@ package convert import ( "time" - repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" - wiki_service "code.gitea.io/gitea/services/wiki" ) // ToWikiCommit convert a git commit into a WikiCommit @@ -46,15 +43,3 @@ func ToWikiCommitList(commits []*git.Commit, total int64) *api.WikiCommitList { Count: total, } } - -// ToWikiPageMetaData converts meta information to a WikiPageMetaData -func ToWikiPageMetaData(wikiName wiki_service.WebPath, lastCommit *git.Commit, repo *repo_model.Repository) *api.WikiPageMetaData { - subURL := string(wikiName) - _, title := wiki_service.WebPathToUserTitle(wikiName) - return &api.WikiPageMetaData{ - Title: title, - HTMLURL: util.URLJoin(repo.HTMLURL(), "wiki", subURL), - SubURL: subURL, - LastCommit: ToWikiCommit(lastCommit), - } -} diff --git a/services/cron/cron.go b/services/cron/cron.go index e3f31d08f0c..3c5737e3718 100644 --- a/services/cron/cron.go +++ b/services/cron/cron.go @@ -106,7 +106,12 @@ func ListTasks() TaskTable { next = e.NextRun() prev = e.PreviousRun() } + task.lock.Lock() + // If the manual run is after the cron run, use that instead. + if prev.Before(task.LastRun) { + prev = task.LastRun + } tTable = append(tTable, &TaskTableRow{ Name: task.Name, Spec: spec, diff --git a/services/cron/setting.go b/services/cron/setting.go index 0656307cba0..6dad88830ab 100644 --- a/services/cron/setting.go +++ b/services/cron/setting.go @@ -70,7 +70,7 @@ func (b *BaseConfig) DoNoticeOnSuccess() bool { // Please note the `status` string will be concatenated with `admin.dashboard.cron.` and `admin.dashboard.task.` to provide locale messages. Similarly `name` will be composed with `admin.dashboard.` to provide the locale name for the task. func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer string, args ...any) string { realArgs := make([]any, 0, len(args)+2) - realArgs = append(realArgs, locale.Tr("admin.dashboard."+name)) + realArgs = append(realArgs, locale.TrString("admin.dashboard."+name)) if doer == "" { realArgs = append(realArgs, "(Cron)") } else { @@ -80,7 +80,7 @@ func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer realArgs = append(realArgs, args...) } if doer == "" { - return locale.Tr("admin.dashboard.cron."+status, realArgs...) + return locale.TrString("admin.dashboard.cron."+status, realArgs...) } - return locale.Tr("admin.dashboard.task."+status, realArgs...) + return locale.TrString("admin.dashboard.task."+status, realArgs...) } diff --git a/services/cron/tasks.go b/services/cron/tasks.go index ea1925c26c7..f8a7444c49e 100644 --- a/services/cron/tasks.go +++ b/services/cron/tasks.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "sync" + "time" "code.gitea.io/gitea/models/db" system_model "code.gitea.io/gitea/models/system" @@ -37,6 +38,8 @@ type Task struct { LastMessage string LastDoer string ExecTimes int64 + // This stores the time of the last manual run of this task. + LastRun time.Time } // DoRunAtStart returns if this task should run at the start @@ -81,13 +84,21 @@ func (t *Task) RunWithUser(doer *user_model.User, config Config) { t.lock.Unlock() defer func() { taskStatusTable.Stop(t.Name) - if err := recover(); err != nil { - // Recover a panic within the - combinedErr := fmt.Errorf("%s\n%s", err, log.Stack(2)) - log.Error("PANIC whilst running task: %s Value: %v", t.Name, combinedErr) - } }() graceful.GetManager().RunWithShutdownContext(func(baseCtx context.Context) { + defer func() { + if err := recover(); err != nil { + // Recover a panic within the execution of the task. + combinedErr := fmt.Errorf("%s\n%s", err, log.Stack(2)) + log.Error("PANIC whilst running task: %s Value: %v", t.Name, combinedErr) + } + }() + // Store the time of this run, before the function is executed, so it + // matches the behavior of what the cron library does. + t.lock.Lock() + t.LastRun = time.Now() + t.lock.Unlock() + pm := process.GetManager() doerName := "" if doer != nil && doer.ID != -1 { @@ -148,7 +159,7 @@ func RegisterTask(name string, config Config, fun func(context.Context, *user_mo log.Debug("Registering task: %s", name) i18nKey := "admin.dashboard." + name - if value := translation.NewLocale("en-US").Tr(i18nKey); value == i18nKey { + if value := translation.NewLocale("en-US").TrString(i18nKey); value == i18nKey { return fmt.Errorf("translation is missing for task %q, please add translation for %q", name, i18nKey) } diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 48ea87df7fb..0018c5facc5 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -8,14 +8,13 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" - asymkey_model "code.gitea.io/gitea/models/asymkey" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/updatechecker" + asymkey_service "code.gitea.io/gitea/services/asymkey" repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" user_service "code.gitea.io/gitea/services/user" @@ -71,8 +70,8 @@ func registerRewriteAllPublicKeys() { Enabled: false, RunAtStart: false, Schedule: "@every 72h", - }, func(_ context.Context, _ *user_model.User, _ Config) error { - return asymkey_model.RewriteAllPublicKeys() + }, func(ctx context.Context, _ *user_model.User, _ Config) error { + return asymkey_service.RewriteAllPublicKeys(ctx) }) } @@ -81,8 +80,8 @@ func registerRewriteAllPrincipalKeys() { Enabled: false, RunAtStart: false, Schedule: "@every 72h", - }, func(_ context.Context, _ *user_model.User, _ Config) error { - return asymkey_model.RewriteAllPrincipalKeys(db.DefaultContext) + }, func(ctx context.Context, _ *user_model.User, _ Config) error { + return asymkey_service.RewriteAllPrincipalKeys(ctx) }) } @@ -136,7 +135,7 @@ func registerDeleteOldActions() { OlderThan: 365 * 24 * time.Hour, }, func(ctx context.Context, _ *user_model.User, config Config) error { olderThanConfig := config.(*OlderThanConfig) - return activities_model.DeleteOldActions(olderThanConfig.OlderThan) + return activities_model.DeleteOldActions(ctx, olderThanConfig.OlderThan) }) } @@ -168,7 +167,7 @@ func registerDeleteOldSystemNotices() { OlderThan: 365 * 24 * time.Hour, }, func(ctx context.Context, _ *user_model.User, config Config) error { olderThanConfig := config.(*OlderThanConfig) - return system.DeleteOldSystemNotices(olderThanConfig.OlderThan) + return system.DeleteOldSystemNotices(ctx, olderThanConfig.OlderThan) }) } @@ -220,7 +219,7 @@ func registerRebuildIssueIndexer() { RunAtStart: false, Schedule: "@annually", }, func(ctx context.Context, _ *user_model.User, config Config) error { - return issue_indexer.PopulateIssueIndexer(ctx, false) + return issue_indexer.PopulateIssueIndexer(ctx) }) } diff --git a/services/cron/tasks_test.go b/services/cron/tasks_test.go index 69052d739c4..979371a0229 100644 --- a/services/cron/tasks_test.go +++ b/services/cron/tasks_test.go @@ -4,6 +4,7 @@ package cron import ( + "sort" "strconv" "testing" @@ -22,9 +23,10 @@ func TestAddTaskToScheduler(t *testing.T) { }, }) assert.NoError(t, err) - assert.Len(t, scheduler.Jobs(), 1) - assert.Equal(t, "task 1", scheduler.Jobs()[0].Tags()[0]) - assert.Equal(t, "5 4 * * *", scheduler.Jobs()[0].Tags()[1]) + jobs := scheduler.Jobs() + assert.Len(t, jobs, 1) + assert.Equal(t, "task 1", jobs[0].Tags()[0]) + assert.Equal(t, "5 4 * * *", jobs[0].Tags()[1]) // with seconds err = addTaskToScheduler(&Task{ @@ -34,9 +36,13 @@ func TestAddTaskToScheduler(t *testing.T) { }, }) assert.NoError(t, err) - assert.Len(t, scheduler.Jobs(), 2) - assert.Equal(t, "task 2", scheduler.Jobs()[1].Tags()[0]) - assert.Equal(t, "30 5 4 * * *", scheduler.Jobs()[1].Tags()[1]) + jobs = scheduler.Jobs() // the item order is not guaranteed, so we need to sort it before "assert" + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].Tags()[0] < jobs[j].Tags()[0] + }) + assert.Len(t, jobs, 2) + assert.Equal(t, "task 2", jobs[1].Tags()[0]) + assert.Equal(t, "30 5 4 * * *", jobs[1].Tags()[1]) } func TestScheduleHasSeconds(t *testing.T) { diff --git a/modules/doctor/authorizedkeys.go b/services/doctor/authorizedkeys.go similarity index 87% rename from modules/doctor/authorizedkeys.go rename to services/doctor/authorizedkeys.go index e4d85c4a18d..8d6fc9cb5e8 100644 --- a/modules/doctor/authorizedkeys.go +++ b/services/doctor/authorizedkeys.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + asymkey_service "code.gitea.io/gitea/services/asymkey" ) const tplCommentPrefix = `# gitea public key` @@ -33,7 +34,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e return fmt.Errorf("Unable to open authorized_keys file. ERROR: %w", err) } logger.Warn("Unable to open authorized_keys. (ERROR: %v). Attempting to rewrite...", err) - if err = asymkey_model.RewriteAllPublicKeys(); err != nil { + if err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil { logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err) return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err) } @@ -50,7 +51,11 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e } linesInAuthorizedKeys.Add(line) } - f.Close() + if err = scanner.Err(); err != nil { + return fmt.Errorf("scan: %w", err) + } + // although there is a "defer close" above, here close explicitly before the generating, because it needs to open the file for writing again + _ = f.Close() // now we regenerate and check if there are any lines missing regenerated := &bytes.Buffer{} @@ -76,7 +81,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e return fmt.Errorf(`authorized_keys is out of date and should be regenerated with "gitea admin regenerate keys" or "gitea doctor --run authorized-keys --fix"`) } logger.Warn("authorized_keys is out of date. Attempting rewrite...") - err = asymkey_model.RewriteAllPublicKeys() + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err) return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err) diff --git a/modules/doctor/breaking.go b/services/doctor/breaking.go similarity index 100% rename from modules/doctor/breaking.go rename to services/doctor/breaking.go diff --git a/modules/doctor/checkOldArchives.go b/services/doctor/checkOldArchives.go similarity index 100% rename from modules/doctor/checkOldArchives.go rename to services/doctor/checkOldArchives.go diff --git a/modules/doctor/dbconsistency.go b/services/doctor/dbconsistency.go similarity index 86% rename from modules/doctor/dbconsistency.go rename to services/doctor/dbconsistency.go index 541fc736f44..dfdf7b547ac 100644 --- a/modules/doctor/dbconsistency.go +++ b/services/doctor/dbconsistency.go @@ -6,6 +6,7 @@ package doctor import ( "context" + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" @@ -60,26 +61,20 @@ func asFixer(fn func(ctx context.Context) error) func(ctx context.Context) (int6 } } -func genericOrphanCheck(name, subject, refobject, joincond string) consistencyCheck { +func genericOrphanCheck(name, subject, refObject, joinCond string) consistencyCheck { return consistencyCheck{ Name: name, Counter: func(ctx context.Context) (int64, error) { - return db.CountOrphanedObjects(ctx, subject, refobject, joincond) + return db.CountOrphanedObjects(ctx, subject, refObject, joinCond) }, Fixer: func(ctx context.Context) (int64, error) { - err := db.DeleteOrphanedObjects(ctx, subject, refobject, joincond) + err := db.DeleteOrphanedObjects(ctx, subject, refObject, joinCond) return -1, err }, } } -func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) error { - // make sure DB version is uptodate - if err := db.InitEngineWithMigration(ctx, migrations.EnsureUpToDate); err != nil { - logger.Critical("Model version on the database does not match the current Gitea version. Model consistency will not be checked until the database is upgraded") - return err - } - +func prepareDBConsistencyChecks() []consistencyCheck { consistencyChecks := []consistencyCheck{ { // find labels without existing repo or org @@ -101,7 +96,7 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er }, // find releases without existing repository genericOrphanCheck("Orphaned Releases without existing repository", - "release", "repository", "release.repo_id=repository.id"), + "release", "repository", "`release`.repo_id=repository.id"), // find pulls without existing issues genericOrphanCheck("Orphaned PullRequests without existing issue", "pull_request", "issue", "pull_request.issue_id=issue.id"), @@ -151,6 +146,18 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er Fixer: activities_model.FixActionCreatedUnixString, FixedMessage: "Set to zero", }, + { + Name: "Action Runners without existing owner", + Counter: actions_model.CountRunnersWithoutBelongingOwner, + Fixer: actions_model.FixRunnersWithoutBelongingOwner, + FixedMessage: "Removed", + }, + { + Name: "Topics with empty repository count", + Counter: repo_model.CountOrphanedTopics, + Fixer: repo_model.DeleteOrphanedTopics, + FixedMessage: "Removed", + }, } // TODO: function to recalc all counters @@ -168,9 +175,9 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er // find protected branches without existing repository genericOrphanCheck("Protected Branches without existing repository", "protected_branch", "repository", "protected_branch.repo_id=repository.id"), - // find deleted branches without existing repository - genericOrphanCheck("Deleted Branches without existing repository", - "deleted_branch", "repository", "deleted_branch.repo_id=repository.id"), + // find branches without existing repository + genericOrphanCheck("Branches without existing repository", + "branch", "repository", "branch.repo_id=repository.id"), // find LFS locks without existing repository genericOrphanCheck("LFS locks without existing repository", "lfs_lock", "repository", "lfs_lock.repo_id=repository.id"), @@ -189,12 +196,15 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er // find action without repository genericOrphanCheck("Action entries without existing repository", "action", "repository", "action.repo_id=repository.id"), + // find action without user + genericOrphanCheck("Action entries without existing user", + "action", "user", "action.act_user_id=`user`.id"), // find OAuth2Grant without existing user genericOrphanCheck("Orphaned OAuth2Grant without existing User", "oauth2_grant", "user", "oauth2_grant.user_id=`user`.id"), // find OAuth2Application without existing user genericOrphanCheck("Orphaned OAuth2Application without existing User", - "oauth2_application", "user", "oauth2_application.uid=`user`.id"), + "oauth2_application", "user", "oauth2_application.uid=0 OR oauth2_application.uid=`user`.id"), // find OAuth2AuthorizationCode without existing OAuth2Grant genericOrphanCheck("Orphaned OAuth2AuthorizationCode without existing OAuth2Grant", "oauth2_authorization_code", "oauth2_grant", "oauth2_authorization_code.grant_id=oauth2_grant.id"), @@ -208,7 +218,16 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er genericOrphanCheck("Orphaned Redirects without existing redirect user", "user_redirect", "user", "user_redirect.redirect_user_id=`user`.id"), ) + return consistencyChecks +} +func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) error { + // make sure DB version is uptodate + if err := db.InitEngineWithMigration(ctx, migrations.EnsureUpToDate); err != nil { + logger.Critical("Model version on the database does not match the current Gitea version. Model consistency will not be checked until the database is upgraded") + return err + } + consistencyChecks := prepareDBConsistencyChecks() for _, c := range consistencyChecks { if err := c.Run(ctx, logger, autofix); err != nil { return err diff --git a/services/doctor/dbconsistency_test.go b/services/doctor/dbconsistency_test.go new file mode 100644 index 00000000000..4e4ac535b7b --- /dev/null +++ b/services/doctor/dbconsistency_test.go @@ -0,0 +1,51 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "slices" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + "github.com/stretchr/testify/assert" +) + +func TestConsistencyCheck(t *testing.T) { + checks := prepareDBConsistencyChecks() + idx := slices.IndexFunc(checks, func(check consistencyCheck) bool { + return check.Name == "Orphaned OAuth2Application without existing User" + }) + if !assert.NotEqual(t, -1, idx) { + return + } + + _ = db.TruncateBeans(db.DefaultContext, &auth.OAuth2Application{}, &user.User{}) + _ = db.TruncateBeans(db.DefaultContext, &auth.OAuth2Application{}, &auth.OAuth2Application{}) + + err := db.Insert(db.DefaultContext, &user.User{ID: 1}) + assert.NoError(t, err) + err = db.Insert(db.DefaultContext, &auth.OAuth2Application{Name: "test-oauth2-app-1", ClientID: "client-id-1"}) + assert.NoError(t, err) + err = db.Insert(db.DefaultContext, &auth.OAuth2Application{Name: "test-oauth2-app-2", ClientID: "client-id-2", UID: 1}) + assert.NoError(t, err) + err = db.Insert(db.DefaultContext, &auth.OAuth2Application{Name: "test-oauth2-app-3", ClientID: "client-id-3", UID: 99999999}) + assert.NoError(t, err) + + unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ClientID: "client-id-1"}) + unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ClientID: "client-id-2"}) + unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ClientID: "client-id-3"}) + + oauth2AppCheck := checks[idx] + err = oauth2AppCheck.Run(db.DefaultContext, log.GetManager().GetLogger(log.DEFAULT), true) + assert.NoError(t, err) + + unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ClientID: "client-id-1"}) + unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ClientID: "client-id-2"}) + unittest.AssertNotExistsBean(t, &auth.OAuth2Application{ClientID: "client-id-3"}) +} diff --git a/modules/doctor/dbversion.go b/services/doctor/dbversion.go similarity index 100% rename from modules/doctor/dbversion.go rename to services/doctor/dbversion.go diff --git a/modules/doctor/doctor.go b/services/doctor/doctor.go similarity index 81% rename from modules/doctor/doctor.go rename to services/doctor/doctor.go index ceee3228521..a4eb5e16b91 100644 --- a/modules/doctor/doctor.go +++ b/services/doctor/doctor.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" ) // Check represents a Doctor check @@ -25,6 +26,7 @@ type Check struct { AbortIfFailed bool SkipDatabaseInitialization bool Priority int + InitStorage bool } func initDBSkipLogger(ctx context.Context) error { @@ -79,10 +81,12 @@ var Checks []*Check // RunChecks runs the doctor checks for the provided list func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) error { + SortChecks(checks) // the checks output logs by a special logger, they do not use the default logger logger := log.BaseLoggerToGeneralLogger(&doctorCheckLogger{colorize: colorize}) loggerStep := log.BaseLoggerToGeneralLogger(&doctorCheckStepLogger{colorize: colorize}) dbIsInit := false + storageIsInit := false for i, check := range checks { if !dbIsInit && !check.SkipDatabaseInitialization { // Only open database after the most basic configuration check @@ -93,6 +97,14 @@ func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) err } dbIsInit = true } + if !storageIsInit && check.InitStorage { + if err := storage.Init(); err != nil { + logger.Error("Error whilst initializing the storage: %v", err) + logger.Error("Check if you are using the right config file. You can use a --config directive to specify one.") + return nil + } + storageIsInit = true + } logger.Info("\n[%d] %s", i+1, check.Title) if err := check.Run(ctx, loggerStep, autofix); err != nil { if check.AbortIfFailed { @@ -104,20 +116,23 @@ func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) err logger.Info("OK") } } - logger.Info("\nAll done.") + logger.Info("\nAll done (checks: %d).", len(checks)) return nil } // Register registers a command with the list func Register(command *Check) { Checks = append(Checks, command) - sort.SliceStable(Checks, func(i, j int) bool { - if Checks[i].Priority == Checks[j].Priority { - return Checks[i].Name < Checks[j].Name +} + +func SortChecks(checks []*Check) { + sort.SliceStable(checks, func(i, j int) bool { + if checks[i].Priority == checks[j].Priority { + return checks[i].Name < checks[j].Name } - if Checks[i].Priority == 0 { + if checks[i].Priority == 0 { return false } - return Checks[i].Priority < Checks[j].Priority + return checks[i].Priority < checks[j].Priority }) } diff --git a/modules/doctor/fix16961.go b/services/doctor/fix16961.go similarity index 98% rename from modules/doctor/fix16961.go rename to services/doctor/fix16961.go index 562c78dd765..50d9ac6621a 100644 --- a/modules/doctor/fix16961.go +++ b/services/doctor/fix16961.go @@ -216,6 +216,12 @@ func fixBrokenRepoUnit16961(repoUnit *repo_model.RepoUnit, bs []byte) (fixed boo return false, nil } + var cfg any + err = json.UnmarshalHandleDoubleEncode(bs, &cfg) + if err == nil { + return false, nil + } + switch repoUnit.Type { case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypeProjects: cfg := &repo_model.UnitConfig{} @@ -290,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo return nil } - return repo_model.UpdateRepoUnit(repoUnit) + return repo_model.UpdateRepoUnit(ctx, repoUnit) }, ) if err != nil { diff --git a/modules/doctor/fix16961_test.go b/services/doctor/fix16961_test.go similarity index 100% rename from modules/doctor/fix16961_test.go rename to services/doctor/fix16961_test.go diff --git a/modules/doctor/fix8312.go b/services/doctor/fix8312.go similarity index 100% rename from modules/doctor/fix8312.go rename to services/doctor/fix8312.go diff --git a/modules/doctor/heads.go b/services/doctor/heads.go similarity index 100% rename from modules/doctor/heads.go rename to services/doctor/heads.go diff --git a/modules/doctor/lfs.go b/services/doctor/lfs.go similarity index 100% rename from modules/doctor/lfs.go rename to services/doctor/lfs.go diff --git a/services/doctor/main_test.go b/services/doctor/main_test.go new file mode 100644 index 00000000000..0f365e21d08 --- /dev/null +++ b/services/doctor/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/modules/doctor/mergebase.go b/services/doctor/mergebase.go similarity index 98% rename from modules/doctor/mergebase.go rename to services/doctor/mergebase.go index e79369e581c..de460c41905 100644 --- a/modules/doctor/mergebase.go +++ b/services/doctor/mergebase.go @@ -74,7 +74,7 @@ func checkPRMergeBase(ctx context.Context, logger log.Logger, autofix bool) erro pr.MergeBase = strings.TrimSpace(pr.MergeBase) if pr.MergeBase != oldMergeBase { if autofix { - if err := pr.UpdateCols("merge_base"); err != nil { + if err := pr.UpdateCols(ctx, "merge_base"); err != nil { logger.Critical("Failed to update merge_base. ERROR: %v", err) return fmt.Errorf("Failed to update merge_base. ERROR: %w", err) } diff --git a/modules/doctor/misc.go b/services/doctor/misc.go similarity index 98% rename from modules/doctor/misc.go rename to services/doctor/misc.go index e01c3e109b6..9300c3a25c9 100644 --- a/modules/doctor/misc.go +++ b/services/doctor/misc.go @@ -16,6 +16,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" @@ -74,7 +75,7 @@ func checkHooks(ctx context.Context, logger log.Logger, autofix bool) error { func checkUserStarNum(ctx context.Context, logger log.Logger, autofix bool) error { if autofix { - if err := models.DoctorUserStarNum(); err != nil { + if err := models.DoctorUserStarNum(ctx); err != nil { logger.Critical("Unable update User Stars numbers") return err } @@ -91,7 +92,7 @@ func checkEnablePushOptions(ctx context.Context, logger log.Logger, autofix bool if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error { numRepos++ - r, err := git.OpenRepository(ctx, repo.RepoPath()) + r, err := gitrepo.OpenRepository(ctx, repo) if err != nil { return err } diff --git a/modules/doctor/paths.go b/services/doctor/paths.go similarity index 100% rename from modules/doctor/paths.go rename to services/doctor/paths.go diff --git a/services/doctor/repository.go b/services/doctor/repository.go new file mode 100644 index 00000000000..6c33426636e --- /dev/null +++ b/services/doctor/repository.go @@ -0,0 +1,80 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/storage" + repo_service "code.gitea.io/gitea/services/repository" + + "xorm.io/builder" +) + +func handleDeleteOrphanedRepos(ctx context.Context, logger log.Logger, autofix bool) error { + test := &consistencyCheck{ + Name: "Repos with no existing owner", + Counter: countOrphanedRepos, + Fixer: deleteOrphanedRepos, + FixedMessage: "Deleted all content related to orphaned repos", + } + return test.Run(ctx, logger, autofix) +} + +// countOrphanedRepos count repository where user of owner_id do not exist +func countOrphanedRepos(ctx context.Context) (int64, error) { + return db.CountOrphanedObjects(ctx, "repository", "user", "repository.owner_id=`user`.id") +} + +// deleteOrphanedRepos delete repository where user of owner_id do not exist +func deleteOrphanedRepos(ctx context.Context) (int64, error) { + if err := storage.Init(); err != nil { + return 0, err + } + + batchSize := db.MaxBatchInsertSize("repository") + e := db.GetEngine(ctx) + var deleted int64 + adminUser := &user_model.User{IsAdmin: true} + + for { + select { + case <-ctx.Done(): + return deleted, ctx.Err() + default: + var ids []int64 + if err := e.Table("`repository`"). + Join("LEFT", "`user`", "repository.owner_id=`user`.id"). + Where(builder.IsNull{"`user`.id"}). + Select("`repository`.id").Limit(batchSize).Find(&ids); err != nil { + return deleted, err + } + + // if we don't get ids we have deleted them all + if len(ids) == 0 { + return deleted, nil + } + + for _, id := range ids { + if err := repo_service.DeleteRepositoryDirectly(ctx, adminUser, id, true); err != nil { + return deleted, err + } + deleted++ + } + } + } +} + +func init() { + Register(&Check{ + Title: "Deleted all content related to orphaned repos", + Name: "delete-orphaned-repos", + IsDefault: false, + Run: handleDeleteOrphanedRepos, + Priority: 4, + }) +} diff --git a/modules/doctor/storage.go b/services/doctor/storage.go similarity index 99% rename from modules/doctor/storage.go rename to services/doctor/storage.go index f3385378646..787df275495 100644 --- a/modules/doctor/storage.go +++ b/services/doctor/storage.go @@ -162,7 +162,7 @@ func checkStorage(opts *checkStorageOptions) func(ctx context.Context, logger lo if opts.RepoArchives || opts.All { if err := commonCheckStorage(ctx, logger, autofix, &commonStorageCheckOptions{ - storer: storage.RepoAvatars, + storer: storage.RepoArchives, isOrphaned: func(path string, obj storage.Object, stat fs.FileInfo) (bool, error) { exists, err := repo.ExistsRepoArchiverWithStoragePath(ctx, path) if err == nil || errors.Is(err, util.ErrInvalidArgument) { diff --git a/modules/doctor/usertype.go b/services/doctor/usertype.go similarity index 100% rename from modules/doctor/usertype.go rename to services/doctor/usertype.go diff --git a/services/externalaccount/link.go b/services/externalaccount/link.go index a19d4c5ab3e..d6e2ea7e942 100644 --- a/services/externalaccount/link.go +++ b/services/externalaccount/link.go @@ -4,6 +4,7 @@ package externalaccount import ( + "context" "fmt" user_model "code.gitea.io/gitea/models/user" @@ -19,11 +20,11 @@ type Store interface { } // LinkAccountFromStore links the provided user with a stored external user -func LinkAccountFromStore(store Store, user *user_model.User) error { +func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error { gothUser := store.Get("linkAccountGothUser") if gothUser == nil { return fmt.Errorf("not in LinkAccount session") } - return LinkAccountToUser(user, gothUser.(goth.User)) + return LinkAccountToUser(ctx, user, gothUser.(goth.User)) } diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index 3da5af34863..e2de41da188 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -4,6 +4,7 @@ package externalaccount import ( + "context" "strings" "code.gitea.io/gitea/models/auth" @@ -15,8 +16,8 @@ import ( "github.com/markbates/goth" ) -func toExternalLoginUser(user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) { - authSource, err := auth.GetActiveOAuth2SourceByName(gothUser.Provider) +func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) { + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) if err != nil { return nil, err } @@ -42,13 +43,13 @@ func toExternalLoginUser(user *user_model.User, gothUser goth.User) (*user_model } // LinkAccountToUser link the gothUser to the user -func LinkAccountToUser(user *user_model.User, gothUser goth.User) error { - externalLoginUser, err := toExternalLoginUser(user, gothUser) +func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { + externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) if err != nil { return err } - if err := user_model.LinkExternalToUser(user, externalLoginUser); err != nil { + if err := user_model.LinkExternalToUser(ctx, user, externalLoginUser); err != nil { return err } @@ -63,38 +64,38 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error { } if tp.Name() != "" { - return UpdateMigrationsByType(tp, externalID, user.ID) + return UpdateMigrationsByType(ctx, tp, externalID, user.ID) } return nil } // UpdateExternalUser updates external user's information -func UpdateExternalUser(user *user_model.User, gothUser goth.User) error { - externalLoginUser, err := toExternalLoginUser(user, gothUser) +func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { + externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) if err != nil { return err } - return user_model.UpdateExternalUserByExternalID(externalLoginUser) + return user_model.UpdateExternalUserByExternalID(ctx, externalLoginUser) } // UpdateMigrationsByType updates all migrated repositories' posterid from gitServiceType to replace originalAuthorID to posterID -func UpdateMigrationsByType(tp structs.GitServiceType, externalUserID string, userID int64) error { - if err := issues_model.UpdateIssuesMigrationsByType(tp, externalUserID, userID); err != nil { +func UpdateMigrationsByType(ctx context.Context, tp structs.GitServiceType, externalUserID string, userID int64) error { + if err := issues_model.UpdateIssuesMigrationsByType(ctx, tp, externalUserID, userID); err != nil { return err } - if err := issues_model.UpdateCommentsMigrationsByType(tp, externalUserID, userID); err != nil { + if err := issues_model.UpdateCommentsMigrationsByType(ctx, tp, externalUserID, userID); err != nil { return err } - if err := repo_model.UpdateReleasesMigrationsByType(tp, externalUserID, userID); err != nil { + if err := repo_model.UpdateReleasesMigrationsByType(ctx, tp, externalUserID, userID); err != nil { return err } - if err := issues_model.UpdateReactionsMigrationsByType(tp, externalUserID, userID); err != nil { + if err := issues_model.UpdateReactionsMigrationsByType(ctx, tp, externalUserID, userID); err != nil { return err } - return issues_model.UpdateReviewsMigrationsByType(tp, externalUserID, userID) + return issues_model.UpdateReviewsMigrationsByType(ctx, tp, externalUserID, userID) } diff --git a/services/feed/action.go b/services/feed/action.go index 6bf1158cc9f..83daaa1438f 100644 --- a/services/feed/action.go +++ b/services/feed/action.go @@ -265,7 +265,7 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model actions = append(actions, action) } - if err := activities_model.NotifyWatchersActions(actions); err != nil { + if err := activities_model.NotifyWatchersActions(ctx, actions); err != nil { log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err) } } diff --git a/services/feed/action_test.go b/services/feed/action_test.go index fd84bb675b1..e1b071d8f60 100644 --- a/services/feed/action_test.go +++ b/services/feed/action_test.go @@ -4,7 +4,6 @@ package feed import ( - "path/filepath" "strings" "testing" @@ -20,9 +19,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestRenameRepoAction(t *testing.T) { diff --git a/services/forms/admin.go b/services/forms/admin.go index 4b3cacc606f..81276f8f46f 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -6,9 +6,9 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) @@ -41,6 +41,7 @@ type AdminEditUserForm struct { Password string `binding:"MaxSize(255)"` Website string `binding:"ValidUrl;MaxSize(255)"` Location string `binding:"MaxSize(50)"` + Language string `binding:"MaxSize(5)"` MaxRepoCreation int Active bool Admin bool diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 25acbbb99e8..c9f3182b3a0 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/org.go b/services/forms/org.go index 6e2d787516d..3677fcf429b 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -7,9 +7,9 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/package_form.go b/services/forms/package_form.go index 2f08dfe9f48..cc940d42d34 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/repo_branch_form.go b/services/forms/repo_branch_form.go index 5deb0ae463a..42e6c85c371 100644 --- a/services/forms/repo_branch_form.go +++ b/services/forms/repo_branch_form.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index b36c8cc9b66..e45a2a16955 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -12,22 +12,15 @@ import ( "code.gitea.io/gitea/models" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/webhook" "gitea.com/go-chi/binding" ) -// _______________________________________ _________.______________________ _______________.___. -// \______ \_ _____/\______ \_____ \ / _____/| \__ ___/\_____ \\______ \__ | | -// | _/| __)_ | ___// | \ \_____ \ | | | | / | \| _// | | -// | | \| \ | | / | \/ \| | | | / | \ | \\____ | -// |____|_ /_______ / |____| \_______ /_______ /|___| |____| \_______ /____|_ // ______| -// \/ \/ \/ \/ \/ \/ \/ - // CreateRepoForm form for creating repository type CreateRepoForm struct { UID int64 `binding:"Required"` @@ -50,7 +43,9 @@ type CreateRepoForm struct { Avatar bool Labels bool ProtectedBranch bool - TrustModel string + + ForkSingleBranch string + ObjectFormatName string } // Validate validates the fields @@ -138,6 +133,7 @@ type RepoSettingForm struct { EnableCode bool EnableWiki bool EnableExternalWiki bool + DefaultWikiBranch string ExternalWikiURL string EnableIssues bool EnableExternalTracker bool @@ -147,6 +143,7 @@ type RepoSettingForm struct { ExternalTrackerRegexpPattern string EnableCloseIssuesViaCommitInAnyBranch bool EnableProjects bool + ProjectsMode string EnableReleases bool EnablePackages bool EnablePulls bool @@ -156,6 +153,7 @@ type RepoSettingForm struct { PullsAllowRebase bool PullsAllowRebaseMerge bool PullsAllowSquash bool + PullsAllowFastForwardOnly bool PullsAllowManualMerge bool PullsDefaultMergeStyle string EnableAutodetectManualMerge bool @@ -209,6 +207,7 @@ type ProtectBranchForm struct { BlockOnOfficialReviewRequests bool BlockOnOutdatedBranch bool DismissStaleApprovals bool + IgnoreStaleApprovals bool RequireSignedCommits bool ProtectedFilePatterns string UnprotectedFilePatterns string @@ -317,7 +316,7 @@ func (f *NewSlackHookForm) Validate(req *http.Request, errs binding.Errors) bind errs = append(errs, binding.Error{ FieldNames: []string{"Channel"}, Classification: "", - Message: ctx.Tr("repo.settings.add_webhook.invalid_channel_name"), + Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_channel_name"), }) } return middleware.Validate(errs, ctx.Data, f, ctx.Locale) @@ -602,8 +601,8 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors) // swagger:model MergePullRequestOption type MergePullRequestForm struct { // required: true - // enum: merge,rebase,rebase-merge,squash,manually-merged - Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,manually-merged)"` + // enum: merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged + Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged)"` MergeTitleField string MergeMessageField string MergeCommitID string // only used for manually-merged @@ -629,6 +628,7 @@ type CodeCommentForm struct { SingleReview bool `form:"single_review"` Reply int64 `form:"reply"` LatestCommitID string + Files []string } // Validate validates the fields diff --git a/services/forms/repo_tag_form.go b/services/forms/repo_tag_form.go index 4dd99f9e32f..0135684737c 100644 --- a/services/forms/repo_tag_form.go +++ b/services/forms/repo_tag_form.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/runner.go b/services/forms/runner.go index 6d16cfce49b..6abfc66fc27 100644 --- a/services/forms/runner.go +++ b/services/forms/runner.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/user_form.go b/services/forms/user_form.go index c0eb03f5547..e2e6c208f7d 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -10,11 +10,11 @@ import ( "strings" auth_model "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/modules/context" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) @@ -109,11 +109,7 @@ func (f *RegisterForm) Validate(req *http.Request, errs binding.Errors) binding. // domains in the whitelist or if it doesn't match any of // domains in the blocklist, if any such list is not empty. func (f *RegisterForm) IsEmailDomainAllowed() bool { - if len(setting.Service.EmailDomainAllowList) == 0 { - return !validation.IsEmailDomainListed(setting.Service.EmailDomainBlockList, f.Email) - } - - return validation.IsEmailDomainListed(setting.Service.EmailDomainAllowList, f.Email) + return user_model.IsEmailDomainAllowed(f.Email) } // MustChangePasswordForm form for updating your password after account creation @@ -365,7 +361,7 @@ func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) bind // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { - Name string `binding:"Required;MaxSize(255)"` + Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"` Scope []string } @@ -449,3 +445,14 @@ func (f *PackageSettingForm) Validate(req *http.Request, errs binding.Errors) bi ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +type BlockUserForm struct { + Action string `binding:"Required;In(block,unblock,note)"` + Blockee string `binding:"Required"` + Note string +} + +func (f *BlockUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/forms/user_form_auth_openid.go b/services/forms/user_form_auth_openid.go index d8137a8d134..ca1c77e3208 100644 --- a/services/forms/user_form_auth_openid.go +++ b/services/forms/user_form_auth_openid.go @@ -6,8 +6,8 @@ package forms import ( "net/http" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" ) diff --git a/services/forms/user_form_hidden_comments.go b/services/forms/user_form_hidden_comments.go index 03e629a553a..c21fddf4786 100644 --- a/services/forms/user_form_hidden_comments.go +++ b/services/forms/user_form_hidden_comments.go @@ -7,8 +7,8 @@ import ( "math/big" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" ) type hiddenCommentTypeGroupsType map[string][]issues_model.CommentType diff --git a/services/gitdiff/csv_test.go b/services/gitdiff/csv_test.go index ac53e2d1efa..c006a7c2bd8 100644 --- a/services/gitdiff/csv_test.go +++ b/services/gitdiff/csv_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "code.gitea.io/gitea/models/db" csv_module "code.gitea.io/gitea/modules/csv" "code.gitea.io/gitea/modules/setting" @@ -190,7 +191,7 @@ c,d,e`, } for n, c := range cases { - diff, err := ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.diff), "") + diff, err := ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.diff), "") if err != nil { t.Errorf("ParsePatch failed: %s", err) } diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index 4cc093e65de..b05c210a0cf 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -29,6 +29,7 @@ import ( "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" @@ -153,7 +154,7 @@ func (d *DiffLine) GetBlobExcerptQuery() string { // GetExpandDirection gets DiffLineExpandDirection func (d *DiffLine) GetExpandDirection() DiffLineExpandDirection { - if d.Type != DiffLineSection || d.SectionInfo == nil || d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx <= 1 { + if d.Type != DiffLineSection || d.SectionInfo == nil || d.SectionInfo.LeftIdx-d.SectionInfo.LastLeftIdx <= 1 || d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx <= 1 { return DiffLineExpandNone } if d.SectionInfo.LastLeftIdx <= 0 && d.SectionInfo.LastRightIdx <= 0 { @@ -285,15 +286,15 @@ type DiffInline struct { // DiffInlineWithUnicodeEscape makes a DiffInline with hidden unicode characters escaped func DiffInlineWithUnicodeEscape(s template.HTML, locale translation.Locale) DiffInline { - status, content := charset.EscapeControlHTML(string(s), locale) - return DiffInline{EscapeStatus: status, Content: template.HTML(content)} + status, content := charset.EscapeControlHTML(s, locale) + return DiffInline{EscapeStatus: status, Content: content} } // DiffInlineWithHighlightCode makes a DiffInline with code highlight and hidden unicode characters escaped func DiffInlineWithHighlightCode(fileName, language, code string, locale translation.Locale) DiffInline { highlighted, _ := highlight.Code(fileName, language, code) status, content := charset.EscapeControlHTML(highlighted, locale) - return DiffInline{EscapeStatus: status, Content: template.HTML(content)} + return DiffInline{EscapeStatus: status, Content: content} } // GetComputedInlineDiffFor computes inline diff for the given line. @@ -494,7 +495,7 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c const cmdDiffHead = "diff --git " // ParsePatch builds a Diff object from a io.Reader and some parameters. -func ParsePatch(maxLines, maxLineCharacters, maxFiles int, reader io.Reader, skipToFile string) (*Diff, error) { +func ParsePatch(ctx context.Context, maxLines, maxLineCharacters, maxFiles int, reader io.Reader, skipToFile string) (*Diff, error) { log.Debug("ParsePatch(%d, %d, %d, ..., %s)", maxLines, maxLineCharacters, maxFiles, skipToFile) var curFile *DiffFile @@ -709,7 +710,7 @@ parsingLoop: curFile.IsAmbiguous = false } // Otherwise do nothing with this line, but now switch to parsing hunks - lineBytes, isFragment, err := parseHunks(curFile, maxLines, maxLineCharacters, input) + lineBytes, isFragment, err := parseHunks(ctx, curFile, maxLines, maxLineCharacters, input) diff.TotalAddition += curFile.Addition diff.TotalDeletion += curFile.Deletion if err != nil { @@ -818,7 +819,7 @@ func skipToNextDiffHead(input *bufio.Reader) (line string, err error) { return line, err } -func parseHunks(curFile *DiffFile, maxLines, maxLineCharacters int, input *bufio.Reader) (lineBytes []byte, isFragment bool, err error) { +func parseHunks(ctx context.Context, curFile *DiffFile, maxLines, maxLineCharacters int, input *bufio.Reader) (lineBytes []byte, isFragment bool, err error) { sb := strings.Builder{} var ( @@ -995,7 +996,7 @@ func parseHunks(curFile *DiffFile, maxLines, maxLineCharacters int, input *bufio oid := strings.TrimPrefix(line[1:], lfs.MetaFileOidPrefix) if len(oid) == 64 { m := &git_model.LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}} - count, err := db.CountByBean(db.DefaultContext, m) + count, err := db.CountByBean(ctx, m) if err == nil && count > 0 { curFile.IsBin = true @@ -1106,7 +1107,7 @@ type DiffOptions struct { // GetDiff builds a Diff between two commits of a repository. // Passing the empty string as beforeCommitID returns a diff from the parent commit. // The whitespaceBehavior is either an empty string or a git flag -func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) { +func GetDiff(ctx context.Context, gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) { repoPath := gitRepo.Path commit, err := gitRepo.GetCommit(opts.AfterCommitID) @@ -1115,10 +1116,15 @@ func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff } cmdDiff := git.NewCommand(gitRepo.Ctx) - if (len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == git.EmptySHA) && commit.ParentCount() == 0 { + objectFormat, err := gitRepo.GetObjectFormat() + if err != nil { + return nil, err + } + + if (len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == objectFormat.EmptyObjectID().String()) && commit.ParentCount() == 0 { cmdDiff.AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/", "-M"). AddArguments(opts.WhitespaceBehavior...). - AddArguments("4b825dc642cb6eb9a060e54bf8d69288fbee4904"). // append empty tree ref + AddDynamicArguments(objectFormat.EmptyTree().String()). AddDynamicArguments(opts.AfterCommitID) } else { actualBeforeCommitID := opts.BeforeCommitID @@ -1165,7 +1171,7 @@ func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff _ = writer.Close() }() - diff, err := ParsePatch(opts.MaxLines, opts.MaxLineCharacters, opts.MaxFiles, reader, parsePatchSkipToFile) + diff, err := ParsePatch(ctx, opts.MaxLines, opts.MaxLineCharacters, opts.MaxFiles, reader, parsePatchSkipToFile) if err != nil { return nil, fmt.Errorf("unable to ParsePatch: %w", err) } @@ -1176,41 +1182,30 @@ func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff for _, diffFile := range diff.Files { - gotVendor := false - gotGenerated := false + isVendored := optional.None[bool]() + isGenerated := optional.None[bool]() if checker != nil { attrs, err := checker.CheckPath(diffFile.Name) if err == nil { - if vendored, has := attrs["linguist-vendored"]; has { - if vendored == "set" || vendored == "true" { - diffFile.IsVendored = true - gotVendor = true - } else { - gotVendor = vendored == "false" - } - } - if generated, has := attrs["linguist-generated"]; has { - if generated == "set" || generated == "true" { - diffFile.IsGenerated = true - gotGenerated = true - } else { - gotGenerated = generated == "false" - } - } - if language, has := attrs["linguist-language"]; has && language != "unspecified" && language != "" { - diffFile.Language = language - } else if language, has := attrs["gitlab-language"]; has && language != "unspecified" && language != "" { - diffFile.Language = language + isVendored = git.AttributeToBool(attrs, git.AttributeLinguistVendored) + isGenerated = git.AttributeToBool(attrs, git.AttributeLinguistGenerated) + + language := git.TryReadLanguageAttribute(attrs) + if language.Has() { + diffFile.Language = language.Value() } } } - if !gotVendor { - diffFile.IsVendored = analyze.IsVendor(diffFile.Name) + if !isVendored.Has() { + isVendored = optional.Some(analyze.IsVendor(diffFile.Name)) } - if !gotGenerated { - diffFile.IsGenerated = analyze.IsGenerated(diffFile.Name) + diffFile.IsVendored = isVendored.Value() + + if !isGenerated.Has() { + isGenerated = optional.Some(analyze.IsGenerated(diffFile.Name)) } + diffFile.IsGenerated = isGenerated.Value() tailSection := diffFile.GetTailSection(gitRepo, opts.BeforeCommitID, opts.AfterCommitID) if tailSection != nil { @@ -1224,8 +1219,8 @@ func GetDiff(gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff } diffPaths := []string{opts.BeforeCommitID + separator + opts.AfterCommitID} - if len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == git.EmptySHA { - diffPaths = []string{git.EmptyTreeSHA, opts.AfterCommitID} + if len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == objectFormat.EmptyObjectID().String() { + diffPaths = []string{objectFormat.EmptyTree().String(), opts.AfterCommitID} } diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = git.GetDiffShortStat(gitRepo.Ctx, repoPath, nil, diffPaths...) if err != nil && strings.Contains(err.Error(), "no merge base") { @@ -1256,12 +1251,15 @@ func GetPullDiffStats(gitRepo *git.Repository, opts *DiffOptions) (*PullDiffStat separator = ".." } - diffPaths := []string{opts.BeforeCommitID + separator + opts.AfterCommitID} - if len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == git.EmptySHA { - diffPaths = []string{git.EmptyTreeSHA, opts.AfterCommitID} + objectFormat, err := gitRepo.GetObjectFormat() + if err != nil { + return nil, err } - var err error + diffPaths := []string{opts.BeforeCommitID + separator + opts.AfterCommitID} + if len(opts.BeforeCommitID) == 0 || opts.BeforeCommitID == objectFormat.EmptyObjectID().String() { + diffPaths = []string{objectFormat.EmptyTree().String(), opts.AfterCommitID} + } _, diff.TotalAddition, diff.TotalDeletion, err = git.GetDiffShortStat(gitRepo.Ctx, repoPath, nil, diffPaths...) if err != nil && strings.Contains(err.Error(), "no merge base") { @@ -1280,7 +1278,7 @@ func GetPullDiffStats(gitRepo *git.Repository, opts *DiffOptions) (*PullDiffStat // SyncAndGetUserSpecificDiff is like GetDiff, except that user specific data such as which files the given user has already viewed on the given PR will also be set // Additionally, the database asynchronously is updated if files have changed since the last review func SyncAndGetUserSpecificDiff(ctx context.Context, userID int64, pull *issues_model.PullRequest, gitRepo *git.Repository, opts *DiffOptions, files ...string) (*Diff, error) { - diff, err := GetDiff(gitRepo, opts, files...) + diff, err := GetDiff(ctx, gitRepo, opts, files...) if err != nil { return nil, err } @@ -1343,12 +1341,12 @@ outer: } } - return diff, err + return diff, nil } // CommentAsDiff returns c.Patch as *Diff -func CommentAsDiff(c *issues_model.Comment) (*Diff, error) { - diff, err := ParsePatch(setting.Git.MaxGitDiffLines, +func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error) { + diff, err := ParsePatch(ctx, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.Patch), "") if err != nil { log.Error("Unable to parse patch: %v", err) @@ -1365,7 +1363,7 @@ func CommentAsDiff(c *issues_model.Comment) (*Diff, error) { } // CommentMustAsDiff executes AsDiff and logs the error instead of returning -func CommentMustAsDiff(c *issues_model.Comment) *Diff { +func CommentMustAsDiff(ctx context.Context, c *issues_model.Comment) *Diff { if c == nil { return nil } @@ -1374,7 +1372,7 @@ func CommentMustAsDiff(c *issues_model.Comment) *Diff { log.Error("PANIC whilst retrieving diff for comment[%d] Error: %v\nStack: %s", c.ID, err, log.Stack(2)) } }() - diff, err := CommentAsDiff(c) + diff, err := CommentAsDiff(ctx, c) if err != nil { log.Warn("CommentMustAsDiff: %v", err) } diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go index e270e46fd45..adcac355a7b 100644 --- a/services/gitdiff/gitdiff_test.go +++ b/services/gitdiff/gitdiff_test.go @@ -175,7 +175,7 @@ diff --git "\\a/README.md" "\\b/README.md" } for _, testcase := range tests { t.Run(testcase.name, func(t *testing.T) { - got, err := ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), testcase.skipTo) + got, err := ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), testcase.skipTo) if (err != nil) != testcase.wantErr { t.Errorf("ParsePatch(%q) error = %v, wantErr %v", testcase.name, err, testcase.wantErr) return @@ -400,7 +400,7 @@ index 6961180..9ba1a00 100644 for _, testcase := range tests { t.Run(testcase.name, func(t *testing.T) { - got, err := ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") + got, err := ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") if (err != nil) != testcase.wantErr { t.Errorf("ParsePatch(%q) error = %v, wantErr %v", testcase.name, err, testcase.wantErr) return @@ -449,21 +449,21 @@ index 0000000..6bb8f39 diffBuilder.WriteString("+line" + strconv.Itoa(i) + "\n") } diff = diffBuilder.String() - result, err := ParsePatch(20, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + result, err := ParsePatch(db.DefaultContext, 20, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("There should not be an error: %v", err) } if !result.Files[0].IsIncomplete { t.Errorf("Files should be incomplete! %v", result.Files[0]) } - result, err = ParsePatch(40, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + result, err = ParsePatch(db.DefaultContext, 40, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("There should not be an error: %v", err) } if result.Files[0].IsIncomplete { t.Errorf("Files should not be incomplete! %v", result.Files[0]) } - result, err = ParsePatch(40, 5, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + result, err = ParsePatch(db.DefaultContext, 40, 5, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("There should not be an error: %v", err) } @@ -494,14 +494,14 @@ index 0000000..6bb8f39 diffBuilder.WriteString("+line" + strconv.Itoa(35) + "\n") diff = diffBuilder.String() - result, err = ParsePatch(20, 4096, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + result, err = ParsePatch(db.DefaultContext, 20, 4096, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("There should not be an error: %v", err) } if !result.Files[0].IsIncomplete { t.Errorf("Files should be incomplete! %v", result.Files[0]) } - result, err = ParsePatch(40, 4096, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + result, err = ParsePatch(db.DefaultContext, 40, 4096, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("There should not be an error: %v", err) } @@ -520,7 +520,7 @@ index 0000000..6bb8f39 Docker Pulls + cut off + cut off` - _, err = ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") + _, err = ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff), "") if err != nil { t.Errorf("ParsePatch failed: %s", err) } @@ -536,7 +536,7 @@ index 0000000..6bb8f39 Docker Pulls + cut off + cut off` - _, err = ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff2), "") + _, err = ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff2), "") if err != nil { t.Errorf("ParsePatch failed: %s", err) } @@ -552,7 +552,7 @@ index 0000000..6bb8f39 Docker Pulls + cut off + cut off` - _, err = ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff2a), "") + _, err = ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff2a), "") if err != nil { t.Errorf("ParsePatch failed: %s", err) } @@ -568,7 +568,7 @@ index 0000000..6bb8f39 Docker Pulls + cut off + cut off` - _, err = ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff3), "") + _, err = ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(diff3), "") if err != nil { t.Errorf("ParsePatch failed: %s", err) } @@ -634,7 +634,7 @@ func TestGetDiffRangeWithWhitespaceBehavior(t *testing.T) { } defer gitRepo.Close() for _, behavior := range []git.TrustedCmdArgs{{"-w"}, {"--ignore-space-at-eol"}, {"-b"}, nil} { - diffs, err := GetDiff(gitRepo, + diffs, err := GetDiff(db.DefaultContext, gitRepo, &DiffOptions{ AfterCommitID: "bd7063cc7c04689c4d082183d32a604ed27a24f9", BeforeCommitID: "559c156f8e0178b71cb44355428f24001b08fc68", @@ -665,6 +665,6 @@ func TestNoCrashes(t *testing.T) { } for _, testcase := range tests { // It shouldn't crash, so don't care about the output. - ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") + ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") } } diff --git a/services/gitdiff/highlightdiff.go b/services/gitdiff/highlightdiff.go index f1e2b1d3cb3..35d48445504 100644 --- a/services/gitdiff/highlightdiff.go +++ b/services/gitdiff/highlightdiff.go @@ -93,10 +93,10 @@ func (hcd *highlightCodeDiff) diffWithHighlight(filename, language, codeA, codeB highlightCodeA, _ := highlight.Code(filename, language, codeA) highlightCodeB, _ := highlight.Code(filename, language, codeB) - highlightCodeA = hcd.convertToPlaceholders(highlightCodeA) - highlightCodeB = hcd.convertToPlaceholders(highlightCodeB) + convertedCodeA := hcd.convertToPlaceholders(string(highlightCodeA)) + convertedCodeB := hcd.convertToPlaceholders(string(highlightCodeB)) - diffs := diffMatchPatch.DiffMain(highlightCodeA, highlightCodeB, true) + diffs := diffMatchPatch.DiffMain(convertedCodeA, convertedCodeB, true) diffs = diffMatchPatch.DiffCleanupEfficiency(diffs) for i := range diffs { diff --git a/services/gitdiff/main_test.go b/services/gitdiff/main_test.go index 7f4243576cc..cd9dcd8cd6f 100644 --- a/services/gitdiff/main_test.go +++ b/services/gitdiff/main_test.go @@ -4,7 +4,6 @@ package gitdiff import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -15,7 +14,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/indexer/notify.go b/services/indexer/notify.go index a07bf38b068..f1e21a2d40e 100644 --- a/services/indexer/notify.go +++ b/services/indexer/notify.go @@ -36,11 +36,11 @@ func (r *indexerNotifier) AdoptRepository(ctx context.Context, doer, u *user_mod func (r *indexerNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { - issue_indexer.UpdateIssueIndexer(issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } func (r *indexerNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { - issue_indexer.UpdateIssueIndexer(issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } func (r *indexerNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { @@ -48,7 +48,7 @@ func (r *indexerNotifier) NewPullRequest(ctx context.Context, pr *issues_model.P log.Error("LoadIssue: %v", err) return } - issue_indexer.UpdateIssueIndexer(pr.Issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID) } func (r *indexerNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { @@ -56,7 +56,7 @@ func (r *indexerNotifier) UpdateComment(ctx context.Context, doer *user_model.Us log.Error("LoadIssue: %v", err) return } - issue_indexer.UpdateIssueIndexer(c.Issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, c.Issue.ID) } func (r *indexerNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { @@ -64,7 +64,7 @@ func (r *indexerNotifier) DeleteComment(ctx context.Context, doer *user_model.Us log.Error("LoadIssue: %v", err) return } - issue_indexer.UpdateIssueIndexer(comment.Issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, comment.Issue.ID) } func (r *indexerNotifier) DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { @@ -120,13 +120,35 @@ func (r *indexerNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_mo } func (r *indexerNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { - issue_indexer.UpdateIssueIndexer(issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } func (r *indexerNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { - issue_indexer.UpdateIssueIndexer(issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } func (r *indexerNotifier) IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) { - issue_indexer.UpdateIssueIndexer(issue.ID) + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, + addedLabels, removedLabels []*issues_model.Label, +) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } diff --git a/services/issue/assignee.go b/services/issue/assignee.go index 27fc695533e..8740a6664a5 100644 --- a/services/issue/assignee.go +++ b/services/issue/assignee.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" @@ -113,10 +114,10 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, return err } - var pemResult bool + canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue) + if isAdd { - pemResult = permReviewer.CanAccessAny(perm.AccessModeRead, unit.TypePullRequests) - if !pemResult { + if !permReviewer.CanAccessAny(perm.AccessModeRead, unit.TypePullRequests) { return issues_model.ErrNotValidReviewRequest{ Reason: "Reviewer can't read", UserID: doer.ID, @@ -124,28 +125,6 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, } } - if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != issues_model.ReviewTypeRequest { - return nil - } - - pemResult = doer.ID == issue.PosterID - if !pemResult { - pemResult = permDoer.CanAccessAny(perm.AccessModeWrite, unit.TypePullRequests) - } - if !pemResult { - pemResult, err = issues_model.IsOfficialReviewer(ctx, issue, doer) - if err != nil { - return err - } - if !pemResult { - return issues_model.ErrNotValidReviewRequest{ - Reason: "Doer can't choose reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } - } - if reviewer.ID == issue.PosterID && issue.OriginalAuthorID == 0 { return issues_model.ErrNotValidReviewRequest{ Reason: "poster of pr can't be reviewer", @@ -153,22 +132,35 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, RepoID: issue.Repo.ID, } } - } else { - if lastreview != nil && lastreview.Type == issues_model.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { + + if canDoerChangeReviewRequests { return nil } - pemResult = permDoer.IsAdmin() - if !pemResult { - return issues_model.ErrNotValidReviewRequest{ - Reason: "Doer is not admin", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } + if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != issues_model.ReviewTypeRequest { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't choose reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, } } - return nil + if canDoerChangeReviewRequests { + return nil + } + + if lastreview != nil && lastreview.Type == issues_model.ReviewTypeRequest && lastreview.ReviewerID == doer.ID { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't remove reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } } // IsValidTeamReviewRequest Check permission for ReviewRequest Team @@ -181,11 +173,7 @@ func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, } } - permission, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) - if err != nil { - log.Error("Unable to GetUserRepoPermission for %-v in %-v#%d", doer, issue.Repo, issue.Index) - return err - } + canDoerChangeReviewRequests := CanDoerChangeReviewRequests(ctx, doer, issue.Repo, issue) if isAdd { if issue.Repo.IsPrivate { @@ -200,30 +188,26 @@ func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, } } - doerCanWrite := permission.CanAccessAny(perm.AccessModeWrite, unit.TypePullRequests) - if !doerCanWrite && doer.ID != issue.PosterID { - official, err := issues_model.IsOfficialReviewer(ctx, issue, doer) - if err != nil { - log.Error("Unable to Check if IsOfficialReviewer for %-v in %-v#%d", doer, issue.Repo, issue.Index) - return err - } - if !official { - return issues_model.ErrNotValidReviewRequest{ - Reason: "Doer can't choose reviewer", - UserID: doer.ID, - RepoID: issue.Repo.ID, - } - } + if canDoerChangeReviewRequests { + return nil } - } else if !permission.IsAdmin() { + return issues_model.ErrNotValidReviewRequest{ - Reason: "Only admin users can remove team requests. Doer is not admin", + Reason: "Doer can't choose reviewer", UserID: doer.ID, RepoID: issue.Repo.ID, } } - return nil + if canDoerChangeReviewRequests { + return nil + } + + return issues_model.ErrNotValidReviewRequest{ + Reason: "Doer can't remove reviewer", + UserID: doer.ID, + RepoID: issue.Repo.ID, + } } // TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it. @@ -242,16 +226,33 @@ func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *use return nil, nil } + return comment, teamReviewRequestNotify(ctx, issue, doer, reviewer, isAdd, comment) +} + +func ReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewNotifers []*ReviewRequestNotifier) { + for _, reviewNotifer := range reviewNotifers { + if reviewNotifer.Reviwer != nil { + notify_service.PullRequestReviewRequest(ctx, issue.Poster, issue, reviewNotifer.Reviwer, reviewNotifer.IsAdd, reviewNotifer.Comment) + } else if reviewNotifer.ReviewTeam != nil { + if err := teamReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifer.ReviewTeam, reviewNotifer.IsAdd, reviewNotifer.Comment); err != nil { + log.Error("teamReviewRequestNotify: %v", err) + } + } + } +} + +// teamReviewRequestNotify notify all user in this team +func teamReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool, comment *issues_model.Comment) error { // notify all user in this team if err := comment.LoadIssue(ctx); err != nil { - return nil, err + return err } members, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ TeamID: reviewer.ID, }) if err != nil { - return nil, err + return err } for _, member := range members { @@ -262,5 +263,52 @@ func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *use notify_service.PullRequestReviewRequest(ctx, doer, issue, member, isAdd, comment) } - return comment, err + return err +} + +// CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR +func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool { + // The poster of the PR can change the reviewers + if doer.ID == issue.PosterID { + return true + } + + // The owner of the repo can change the reviewers + if doer.ID == repo.OwnerID { + return true + } + + // Collaborators of the repo can change the reviewers + isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, doer.ID) + if err != nil { + log.Error("IsCollaborator: %v", err) + return false + } + if isCollaborator { + return true + } + + // If the repo's owner is an organization, members of teams with read permission on pull requests can change reviewers + if repo.Owner.IsOrganization() { + teams, err := organization.GetTeamsWithAccessToRepo(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead) + if err != nil { + log.Error("GetTeamsWithAccessToRepo: %v", err) + return false + } + for _, team := range teams { + if !team.UnitEnabled(ctx, unit.TypePullRequests) { + continue + } + isMember, err := organization.IsTeamMember(ctx, repo.OwnerID, team.ID, doer.ID) + if err != nil { + log.Error("IsTeamMember: %v", err) + continue + } + if isMember { + return true + } + } + } + + return false } diff --git a/services/issue/assignee_test.go b/services/issue/assignee_test.go index f47ef45ba00..da25da60ee1 100644 --- a/services/issue/assignee_test.go +++ b/services/issue/assignee_test.go @@ -18,8 +18,12 @@ func TestDeleteNotPassedAssignee(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) // Fake issue with assignees - issue, err := issues_model.GetIssueWithAttrsByID(1) + issue, err := issues_model.GetIssueByID(db.DefaultContext, 1) assert.NoError(t, err) + + err = issue.LoadAttributes(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, issue.Assignees, 1) user1, err := user_model.GetUserByID(db.DefaultContext, 1) // This user is already assigned (see the definition in fixtures), so running UpdateAssignee should unassign him diff --git a/services/issue/comments.go b/services/issue/comments.go index 4a8574edd51..d68623aff68 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" @@ -21,6 +22,12 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod return fmt.Errorf("cannot create reference with empty commit SHA") } + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + // Check if same reference from same commit has already existed. has, err := db.GetEngine(ctx).Get(&issues_model.Comment{ Type: issues_model.CommentTypeCommitRef, @@ -46,6 +53,12 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod // CreateIssueComment creates a plain issue comment. func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) { + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { + return nil, user_model.ErrBlockedUser + } + } + comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeComment, Doer: doer, @@ -70,6 +83,19 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m // UpdateComment updates information of comment. func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_model.User, oldContent string) error { + if err := c.LoadIssue(ctx); err != nil { + return err + } + if err := c.Issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, c.Issue.PosterID, c.Issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, c.Issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport() if needsContentHistory { hasContentHistory, err := issues_model.HasIssueContentHistory(ctx, c.IssueID, c.ID) @@ -84,7 +110,7 @@ func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_mode } } - if err := issues_model.UpdateComment(c, doer); err != nil { + if err := issues_model.UpdateComment(ctx, c, doer); err != nil { return err } diff --git a/services/issue/commit.go b/services/issue/commit.go index e493a032114..0a59088d127 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -5,6 +5,7 @@ package issue import ( "context" + "errors" "fmt" "html" "net/url" @@ -160,6 +161,9 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m message := fmt.Sprintf(`%s`, html.EscapeString(repo.Link()), html.EscapeString(url.PathEscape(c.Sha1)), html.EscapeString(strings.SplitN(c.Message, "\n", 2)[0])) if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + continue + } return err } diff --git a/services/issue/content.go b/services/issue/content.go index 41f1bfefe8c..2f9bee806a9 100644 --- a/services/issue/content.go +++ b/services/issue/content.go @@ -7,15 +7,26 @@ import ( "context" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" user_model "code.gitea.io/gitea/models/user" notify_service "code.gitea.io/gitea/services/notify" ) // ChangeContent changes issue content, as the given user. -func ChangeContent(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string) (err error) { +func ChangeContent(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string) error { + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + oldContent := issue.Content - if err := issues_model.ChangeIssueContent(issue, doer, content); err != nil { + if err := issues_model.ChangeIssueContent(ctx, issue, doer, content); err != nil { return err } diff --git a/services/issue/issue.go b/services/issue/issue.go index 828599be6bc..c7fa9f3300a 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -15,21 +15,40 @@ import ( repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" notify_service "code.gitea.io/gitea/services/notify" ) // NewIssue creates new issue with labels for repository. -func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { - if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil { +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error { + if err := issue.LoadPoster(ctx); err != nil { return err } - for _, assigneeID := range assigneeIDs { - if _, err := AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, true); err != nil { + if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) { + return user_model.ErrBlockedUser + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { return err } + for _, assigneeID := range assigneeIDs { + if _, err := AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, true); err != nil { + return err + } + } + if projectID > 0 { + if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil { + return err + } + } + return nil + }); err != nil { + return err } mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content) @@ -53,17 +72,35 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode oldTitle := issue.Title issue.Title = title + if oldTitle == title { + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil { return err } + var reviewNotifers []*ReviewRequestNotifier if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issues_model.HasWorkInProgressPrefix(title) { - if err := issues_model.PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest); err != nil { - return err + var err error + reviewNotifers, err = PullRequestCodeOwnersReview(ctx, issue, issue.PullRequest) + if err != nil { + log.Error("PullRequestCodeOwnersReview: %v", err) } } notify_service.IssueChangeTitle(ctx, doer, issue, oldTitle) + ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifers) return nil } @@ -73,7 +110,7 @@ func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_m oldRef := issue.Ref issue.Ref = ref - if err := issues_model.ChangeIssueRef(issue, doer, oldRef); err != nil { + if err := issues_model.ChangeIssueRef(ctx, issue, doer, oldRef); err != nil { return err } @@ -89,31 +126,25 @@ func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_m // Pass one or more user logins to replace the set of assignees on this Issue. // Send an empty array ([]) to clear all assignees from the Issue. func UpdateAssignees(ctx context.Context, issue *issues_model.Issue, oneAssignee string, multipleAssignees []string, doer *user_model.User) (err error) { - var allNewAssignees []*user_model.User + uniqueAssignees := container.SetOf(multipleAssignees...) // Keep the old assignee thingy for compatibility reasons if oneAssignee != "" { - // Prevent double adding assignees - var isDouble bool - for _, assignee := range multipleAssignees { - if assignee == oneAssignee { - isDouble = true - break - } - } - - if !isDouble { - multipleAssignees = append(multipleAssignees, oneAssignee) - } + uniqueAssignees.Add(oneAssignee) } // Loop through all assignees to add them - for _, assigneeName := range multipleAssignees { + allNewAssignees := make([]*user_model.User, 0, len(uniqueAssignees)) + for _, assigneeName := range uniqueAssignees.Values() { assignee, err := user_model.GetUserByName(ctx, assigneeName) if err != nil { return err } + if user_model.IsUserBlockedBy(ctx, doer, assignee.ID) { + return user_model.ErrBlockedUser + } + allNewAssignees = append(allNewAssignees, assignee) } @@ -262,45 +293,27 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { } // delete all database data still assigned to this issue - if err := issues_model.DeleteInIssue(ctx, issue.ID, - &issues_model.ContentHistory{}, - &issues_model.Comment{}, - &issues_model.IssueLabel{}, - &issues_model.IssueDependency{}, - &issues_model.IssueAssignees{}, - &issues_model.IssueUser{}, - &activities_model.Notification{}, - &issues_model.Reaction{}, - &issues_model.IssueWatch{}, - &issues_model.Stopwatch{}, - &issues_model.TrackedTime{}, - &project_model.ProjectIssue{}, - &repo_model.Attachment{}, - &issues_model.PullRequest{}, + if err := db.DeleteBeans(ctx, + &issues_model.ContentHistory{IssueID: issue.ID}, + &issues_model.Comment{IssueID: issue.ID}, + &issues_model.IssueLabel{IssueID: issue.ID}, + &issues_model.IssueDependency{IssueID: issue.ID}, + &issues_model.IssueAssignees{IssueID: issue.ID}, + &issues_model.IssueUser{IssueID: issue.ID}, + &activities_model.Notification{IssueID: issue.ID}, + &issues_model.Reaction{IssueID: issue.ID}, + &issues_model.IssueWatch{IssueID: issue.ID}, + &issues_model.Stopwatch{IssueID: issue.ID}, + &issues_model.TrackedTime{IssueID: issue.ID}, + &project_model.ProjectIssue{IssueID: issue.ID}, + &repo_model.Attachment{IssueID: issue.ID}, + &issues_model.PullRequest{IssueID: issue.ID}, + &issues_model.Comment{RefIssueID: issue.ID}, + &issues_model.IssueDependency{DependencyID: issue.ID}, + &issues_model.Comment{DependentIssueID: issue.ID}, ); err != nil { return err } - // References to this issue in other issues - if _, err := db.DeleteByBean(ctx, &issues_model.Comment{ - RefIssueID: issue.ID, - }); err != nil { - return err - } - - // Delete dependencies for issues in other repositories - if _, err := db.DeleteByBean(ctx, &issues_model.IssueDependency{ - DependencyID: issue.ID, - }); err != nil { - return err - } - - // delete from dependent issues - if _, err := db.DeleteByBean(ctx, &issues_model.Comment{ - DependentIssueID: issue.ID, - }); err != nil { - return err - } - return committer.Commit() } diff --git a/services/issue/issue_test.go b/services/issue/issue_test.go index 1f6a77096e8..8806cec0e7c 100644 --- a/services/issue/issue_test.go +++ b/services/issue/issue_test.go @@ -72,7 +72,7 @@ func TestIssue_DeleteIssue(t *testing.T) { assert.NoError(t, err) issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2) assert.NoError(t, err) - err = issues_model.CreateIssueDependency(user, issue1, issue2) + err = issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2) assert.NoError(t, err) left, err := issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1) assert.NoError(t, err) diff --git a/services/issue/label.go b/services/issue/label.go index f830aab0e79..6b8070d8aab 100644 --- a/services/issue/label.go +++ b/services/issue/label.go @@ -4,6 +4,8 @@ package issue import ( + "context" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" @@ -12,49 +14,49 @@ import ( ) // ClearLabels clears all of an issue's labels -func ClearLabels(issue *issues_model.Issue, doer *user_model.User) error { - if err := issues_model.ClearIssueLabels(issue, doer); err != nil { +func ClearLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User) error { + if err := issues_model.ClearIssueLabels(ctx, issue, doer); err != nil { return err } - notify_service.IssueClearLabels(db.DefaultContext, doer, issue) + notify_service.IssueClearLabels(ctx, doer, issue) return nil } // AddLabel adds a new label to the issue. -func AddLabel(issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { - if err := issues_model.NewIssueLabel(issue, label, doer); err != nil { +func AddLabel(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { + if err := issues_model.NewIssueLabel(ctx, issue, label, doer); err != nil { return err } - notify_service.IssueChangeLabels(db.DefaultContext, doer, issue, []*issues_model.Label{label}, nil) + notify_service.IssueChangeLabels(ctx, doer, issue, []*issues_model.Label{label}, nil) return nil } // AddLabels adds a list of new labels to the issue. -func AddLabels(issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { - if err := issues_model.NewIssueLabels(issue, labels, doer); err != nil { +func AddLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { + if err := issues_model.NewIssueLabels(ctx, issue, labels, doer); err != nil { return err } - notify_service.IssueChangeLabels(db.DefaultContext, doer, issue, labels, nil) + notify_service.IssueChangeLabels(ctx, doer, issue, labels, nil) return nil } // RemoveLabel removes a label from issue by given ID. -func RemoveLabel(issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { - ctx, committer, err := db.TxContext(db.DefaultContext) +func RemoveLabel(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error { + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err := issue.LoadRepo(ctx); err != nil { + if err := issue.LoadRepo(dbCtx); err != nil { return err } - perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + perm, err := access_model.GetUserRepoPermission(dbCtx, issue.Repo, doer) if err != nil { return err } @@ -65,7 +67,7 @@ func RemoveLabel(issue *issues_model.Issue, doer *user_model.User, label *issues return issues_model.ErrRepoLabelNotExist{} } - if err := issues_model.DeleteIssueLabel(ctx, issue, label, doer); err != nil { + if err := issues_model.DeleteIssueLabel(dbCtx, issue, label, doer); err != nil { return err } @@ -73,21 +75,21 @@ func RemoveLabel(issue *issues_model.Issue, doer *user_model.User, label *issues return err } - notify_service.IssueChangeLabels(db.DefaultContext, doer, issue, nil, []*issues_model.Label{label}) + notify_service.IssueChangeLabels(ctx, doer, issue, nil, []*issues_model.Label{label}) return nil } // ReplaceLabels removes all current labels and add new labels to the issue. -func ReplaceLabels(issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { - old, err := issues_model.GetLabelsByIssueID(db.DefaultContext, issue.ID) +func ReplaceLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, labels []*issues_model.Label) error { + old, err := issues_model.GetLabelsByIssueID(ctx, issue.ID) if err != nil { return err } - if err := issues_model.ReplaceIssueLabels(issue, labels, doer); err != nil { + if err := issues_model.ReplaceIssueLabels(ctx, issue, labels, doer); err != nil { return err } - notify_service.IssueChangeLabels(db.DefaultContext, doer, issue, labels, old) + notify_service.IssueChangeLabels(ctx, doer, issue, labels, old) return nil } diff --git a/services/issue/label_test.go b/services/issue/label_test.go index af220601f14..90608c9e269 100644 --- a/services/issue/label_test.go +++ b/services/issue/label_test.go @@ -6,6 +6,7 @@ package issue import ( "testing" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -32,7 +33,7 @@ func TestIssue_AddLabels(t *testing.T) { labels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID}) } doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID}) - assert.NoError(t, AddLabels(issue, doer, labels)) + assert.NoError(t, AddLabels(db.DefaultContext, issue, doer, labels)) for _, labelID := range test.labelIDs { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: labelID}) } @@ -55,7 +56,7 @@ func TestIssue_AddLabel(t *testing.T) { issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: test.issueID}) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: test.labelID}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID}) - assert.NoError(t, AddLabel(issue, doer, label)) + assert.NoError(t, AddLabel(db.DefaultContext, issue, doer, label)) unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: test.labelID}) } } diff --git a/services/issue/main_test.go b/services/issue/main_test.go index 6bce694ccaf..5dac54183ba 100644 --- a/services/issue/main_test.go +++ b/services/issue/main_test.go @@ -4,7 +4,6 @@ package issue import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -13,7 +12,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/issue/milestone.go b/services/issue/milestone.go index 5a07cfd2e53..ff645744a7d 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -63,14 +63,14 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is } // ChangeMilestoneAssign changes assignment of milestone for issue. -func ChangeMilestoneAssign(issue *issues_model.Issue, doer *user_model.User, oldMilestoneID int64) (err error) { - ctx, committer, err := db.TxContext(db.DefaultContext) +func ChangeMilestoneAssign(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, oldMilestoneID int64) (err error) { + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err = changeMilestoneAssign(ctx, doer, issue, oldMilestoneID); err != nil { + if err = changeMilestoneAssign(dbCtx, doer, issue, oldMilestoneID); err != nil { return err } @@ -78,7 +78,7 @@ func ChangeMilestoneAssign(issue *issues_model.Issue, doer *user_model.User, old return fmt.Errorf("Commit: %w", err) } - notify_service.IssueChangeMilestone(db.DefaultContext, doer, issue, oldMilestoneID) + notify_service.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID) return nil } diff --git a/services/issue/milestone_test.go b/services/issue/milestone_test.go index 069117d1f1c..42b910166f5 100644 --- a/services/issue/milestone_test.go +++ b/services/issue/milestone_test.go @@ -6,6 +6,7 @@ package issue import ( "testing" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -22,7 +23,7 @@ func TestChangeMilestoneAssign(t *testing.T) { oldMilestoneID := issue.MilestoneID issue.MilestoneID = 2 - assert.NoError(t, ChangeMilestoneAssign(issue, doer, oldMilestoneID)) + assert.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID)) unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ IssueID: issue.ID, Type: issues_model.CommentTypeMilestone, diff --git a/services/issue/pull.go b/services/issue/pull.go new file mode 100644 index 00000000000..b7b63a70246 --- /dev/null +++ b/services/issue/pull.go @@ -0,0 +1,147 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + org_model "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +func getMergeBase(repo *git.Repository, pr *issues_model.PullRequest, baseBranch, headBranch string) (string, error) { + // Add a temporary remote + tmpRemote := fmt.Sprintf("mergebase-%d-%d", pr.ID, time.Now().UnixNano()) + if err := repo.AddRemote(tmpRemote, repo.Path, false); err != nil { + return "", fmt.Errorf("AddRemote: %w", err) + } + defer func() { + if err := repo.RemoveRemote(tmpRemote); err != nil { + log.Error("getMergeBase: RemoveRemote: %v", err) + } + }() + + mergeBase, _, err := repo.GetMergeBase(tmpRemote, baseBranch, headBranch) + return mergeBase, err +} + +type ReviewRequestNotifier struct { + Comment *issues_model.Comment + IsAdd bool + Reviwer *user_model.User + ReviewTeam *org_model.Team +} + +func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue, pr *issues_model.PullRequest) ([]*ReviewRequestNotifier, error) { + files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"} + + if pr.IsWorkInProgress(ctx) { + return nil, nil + } + + if err := pr.LoadHeadRepo(ctx); err != nil { + return nil, err + } + + if pr.HeadRepo.IsFork { + return nil, nil + } + + if err := pr.LoadBaseRepo(ctx); err != nil { + return nil, err + } + + repo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + return nil, err + } + defer repo.Close() + + commit, err := repo.GetBranchCommit(pr.BaseRepo.DefaultBranch) + if err != nil { + return nil, err + } + + var data string + for _, file := range files { + if blob, err := commit.GetBlobByPath(file); err == nil { + data, err = blob.GetBlobContent(setting.UI.MaxDisplayFileSize) + if err == nil { + break + } + } + } + + rules, _ := issues_model.GetCodeOwnersFromContent(ctx, data) + + // get the mergebase + mergeBase, err := getMergeBase(repo, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName()) + if err != nil { + return nil, err + } + + // https://github.com/go-gitea/gitea/issues/29763, we need to get the files changed + // between the merge base and the head commit but not the base branch and the head commit + changedFiles, err := repo.GetFilesChangedBetween(mergeBase, pr.GetGitRefName()) + if err != nil { + return nil, err + } + + uniqUsers := make(map[int64]*user_model.User) + uniqTeams := make(map[string]*org_model.Team) + for _, rule := range rules { + for _, f := range changedFiles { + if (rule.Rule.MatchString(f) && !rule.Negative) || (!rule.Rule.MatchString(f) && rule.Negative) { + for _, u := range rule.Users { + uniqUsers[u.ID] = u + } + for _, t := range rule.Teams { + uniqTeams[fmt.Sprintf("%d/%d", t.OrgID, t.ID)] = t + } + } + } + } + + notifiers := make([]*ReviewRequestNotifier, 0, len(uniqUsers)+len(uniqTeams)) + + if err := issue.LoadPoster(ctx); err != nil { + return nil, err + } + + for _, u := range uniqUsers { + if u.ID != issue.Poster.ID { + comment, err := issues_model.AddReviewRequest(ctx, issue, u, issue.Poster) + if err != nil { + log.Warn("Failed add assignee user: %s to PR review: %s#%d, error: %s", u.Name, pr.BaseRepo.Name, pr.ID, err) + return nil, err + } + notifiers = append(notifiers, &ReviewRequestNotifier{ + Comment: comment, + IsAdd: true, + Reviwer: u, + }) + } + } + for _, t := range uniqTeams { + comment, err := issues_model.AddTeamReviewRequest(ctx, issue, t, issue.Poster) + if err != nil { + log.Warn("Failed add assignee team: %s to PR review: %s#%d, error: %s", t.Name, pr.BaseRepo.Name, pr.ID, err) + return nil, err + } + notifiers = append(notifiers, &ReviewRequestNotifier{ + Comment: comment, + IsAdd: true, + ReviewTeam: t, + }) + } + + return notifiers, nil +} diff --git a/services/issue/reaction.go b/services/issue/reaction.go new file mode 100644 index 00000000000..deb99169e1b --- /dev/null +++ b/services/issue/reaction.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" +) + +// CreateIssueReaction creates a reaction on an issue. +func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + return nil, user_model.ErrBlockedUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + }) +} + +// CreateCommentReaction creates a reaction on a comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { + if err := comment.LoadIssue(ctx); err != nil { + return nil, err + } + + if err := comment.Issue.LoadRepo(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, comment.Issue.PosterID, comment.Issue.Repo.OwnerID, comment.PosterID) { + return nil, user_model.ErrBlockedUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: comment.Issue.ID, + CommentID: comment.ID, + }) +} diff --git a/models/issues/reaction_test.go b/services/issue/reaction_test.go similarity index 63% rename from models/issues/reaction_test.go rename to services/issue/reaction_test.go index ceb7f2c2a67..7734860fc0d 100644 --- a/models/issues/reaction_test.go +++ b/services/issue/reaction_test.go @@ -1,7 +1,7 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package issues_test +package issue import ( "testing" @@ -16,13 +16,13 @@ import ( "github.com/stretchr/testify/assert" ) -func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) { +func addReaction(t *testing.T, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) { var reaction *issues_model.Reaction var err error - if commentID == 0 { - reaction, err = issues_model.CreateIssueReaction(doerID, issueID, content) + if comment == nil { + reaction, err = CreateIssueReaction(db.DefaultContext, doer, issue, content) } else { - reaction, err = issues_model.CreateCommentReaction(doerID, issueID, commentID, content) + reaction, err = CreateCommentReaction(db.DefaultContext, doer, comment, content) } assert.NoError(t, err) assert.NotNil(t, reaction) @@ -32,32 +32,26 @@ func TestIssueAddReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 + addReaction(t, user1, issue, nil, "heart") - addReaction(t, user1.ID, issue1ID, 0, "heart") - - unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) } func TestIssueAddDuplicateReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, 0, "heart") + addReaction(t, user1, issue, nil, "heart") - reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{ - DoerID: user1.ID, - IssueID: issue1ID, - Type: "heart", - }) + reaction, err := CreateIssueReaction(db.DefaultContext, user1, issue, "heart") assert.Error(t, err) assert.Equal(t, issues_model.ErrReactionAlreadyExist{Reaction: "heart"}, err) - existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) assert.Equal(t, existingR.ID, reaction.ID) } @@ -65,15 +59,14 @@ func TestIssueDeleteReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 + addReaction(t, user1, issue, nil, "heart") - addReaction(t, user1.ID, issue1ID, 0, "heart") - - err := issues_model.DeleteIssueReaction(user1.ID, issue1ID, "heart") + err := issues_model.DeleteIssueReaction(db.DefaultContext, user1.ID, issue.ID, "heart") assert.NoError(t, err) - unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) } func TestIssueReactionCount(t *testing.T) { @@ -87,19 +80,19 @@ func TestIssueReactionCount(t *testing.T) { user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) ghost := user_model.NewGhostUser() - var issueID int64 = 2 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - addReaction(t, user1.ID, issueID, 0, "heart") - addReaction(t, user2.ID, issueID, 0, "heart") - addReaction(t, org3.ID, issueID, 0, "heart") - addReaction(t, org3.ID, issueID, 0, "+1") - addReaction(t, user4.ID, issueID, 0, "+1") - addReaction(t, user4.ID, issueID, 0, "heart") - addReaction(t, ghost.ID, issueID, 0, "-1") + addReaction(t, user1, issue, nil, "heart") + addReaction(t, user2, issue, nil, "heart") + addReaction(t, org3, issue, nil, "heart") + addReaction(t, org3, issue, nil, "+1") + addReaction(t, user4, issue, nil, "+1") + addReaction(t, user4, issue, nil, "heart") + addReaction(t, ghost, issue, nil, "-1") reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{ - IssueID: issueID, + IssueID: issue.ID, }) assert.NoError(t, err) assert.Len(t, reactionsList, 7) @@ -122,13 +115,11 @@ func TestIssueCommentAddReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") + addReaction(t, user1, nil, comment, "heart") - unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID}) } func TestIssueCommentDeleteReaction(t *testing.T) { @@ -139,17 +130,16 @@ func TestIssueCommentDeleteReaction(t *testing.T) { org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") - addReaction(t, user2.ID, issue1ID, comment1ID, "heart") - addReaction(t, org3.ID, issue1ID, comment1ID, "heart") - addReaction(t, user4.ID, issue1ID, comment1ID, "+1") + addReaction(t, user1, nil, comment, "heart") + addReaction(t, user2, nil, comment, "heart") + addReaction(t, org3, nil, comment, "heart") + addReaction(t, user4, nil, comment, "+1") reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{ - IssueID: issue1ID, - CommentID: comment1ID, + IssueID: comment.IssueID, + CommentID: comment.ID, }) assert.NoError(t, err) assert.Len(t, reactionsList, 4) @@ -163,12 +153,10 @@ func TestIssueCommentReactionCount(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") - assert.NoError(t, issues_model.DeleteCommentReaction(user1.ID, issue1ID, comment1ID, "heart")) + addReaction(t, user1, nil, comment, "heart") + assert.NoError(t, issues_model.DeleteCommentReaction(db.DefaultContext, user1.ID, comment.IssueID, comment.ID, "heart")) - unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID}) } diff --git a/services/issue/template.go b/services/issue/template.go index 4f1e3d93a0a..dd9d015f0fa 100644 --- a/services/issue/template.go +++ b/services/issue/template.go @@ -72,7 +72,7 @@ func GetTemplateConfig(gitRepo *git.Repository, path string, commit *git.Commit) return GetDefaultTemplateConfig(), err } - issueConfig := api.IssueConfig{} + issueConfig := GetDefaultTemplateConfig() if err := yaml.Unmarshal(configContent, &issueConfig); err != nil { return GetDefaultTemplateConfig(), err } @@ -109,21 +109,23 @@ func IsTemplateConfig(path string) bool { return false } -// GetTemplatesFromDefaultBranch checks for issue templates in the repo's default branch, -// returns valid templates and the errors of invalid template files. -func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) ([]*api.IssueTemplate, map[string]error) { - var issueTemplates []*api.IssueTemplate - +// ParseTemplatesFromDefaultBranch parses the issue templates in the repo's default branch, +// returns valid templates and the errors of invalid template files (the errors map is guaranteed to be non-nil). +func ParseTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) (ret struct { + IssueTemplates []*api.IssueTemplate + TemplateErrors map[string]error +}, +) { + ret.TemplateErrors = map[string]error{} if repo.IsEmpty { - return issueTemplates, nil + return ret } commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) if err != nil { - return issueTemplates, nil + return ret } - invalidFiles := map[string]error{} for _, dirName := range templateDirCandidates { tree, err := commit.SubTree(dirName) if err != nil { @@ -133,7 +135,7 @@ func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repositor entries, err := tree.ListEntries() if err != nil { log.Debug("list entries in %s: %v", dirName, err) - return issueTemplates, nil + return ret } for _, entry := range entries { if !template.CouldBe(entry.Name()) { @@ -141,16 +143,16 @@ func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repositor } fullName := path.Join(dirName, entry.Name()) if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil { - invalidFiles[fullName] = err + ret.TemplateErrors[fullName] = err } else { if !strings.HasPrefix(it.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/ it.Ref = git.BranchPrefix + it.Ref } - issueTemplates = append(issueTemplates, it) + ret.IssueTemplates = append(ret.IssueTemplates, it) } } } - return issueTemplates, invalidFiles + return ret } // GetTemplateConfigFromDefaultBranch returns the issue config for this repo. @@ -179,8 +181,8 @@ func GetTemplateConfigFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repo } func HasTemplatesOrContactLinks(repo *repo.Repository, gitRepo *git.Repository) bool { - ret, _ := GetTemplatesFromDefaultBranch(repo, gitRepo) - if len(ret) > 0 { + ret := ParseTemplatesFromDefaultBranch(repo, gitRepo) + if len(ret.IssueTemplates) > 0 { return true } diff --git a/services/lfs/locks.go b/services/lfs/locks.go index 08d74326567..2a362b1c0d4 100644 --- a/services/lfs/locks.go +++ b/services/lfs/locks.go @@ -11,12 +11,12 @@ import ( auth_model "code.gitea.io/gitea/models/auth" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" lfs_module "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) diff --git a/services/lfs/server.go b/services/lfs/server.go index 58b4663345c..706be0d080e 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -5,6 +5,7 @@ package lfs import ( stdCtx "context" + "crypto/sha256" "encoding/base64" "encoding/hex" "errors" @@ -25,15 +26,14 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" lfs_module "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/services/context" "github.com/golang-jwt/jwt/v5" - "github.com/minio/sha256-simd" ) // requestContext contain variables from the HTTP request. @@ -232,7 +232,7 @@ func BatchHandler(ctx *context.Context) { return } if accessible { - _, err := git_model.NewLFSMetaObject(ctx, &git_model.LFSMetaObject{Pointer: p, RepositoryID: repository.ID}) + _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) if err != nil { log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err) writeStatus(ctx, http.StatusInternalServerError) @@ -325,7 +325,7 @@ func UploadHandler(ctx *context.Context) { log.Error("Error putting LFS MetaObject [%s] into content store. Error: %v", p.Oid, err) return err } - _, err := git_model.NewLFSMetaObject(ctx, &git_model.LFSMetaObject{Pointer: p, RepositoryID: repository.ID}) + _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) return err } diff --git a/services/mailer/incoming/incoming_handler.go b/services/mailer/incoming/incoming_handler.go index b594e35189b..dc0b5398221 100644 --- a/services/mailer/incoming/incoming_handler.go +++ b/services/mailer/incoming/incoming_handler.go @@ -14,9 +14,9 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" attachment_service "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context/upload" issue_service "code.gitea.io/gitea/services/issue" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" "code.gitea.io/gitea/services/mailer/token" @@ -87,7 +87,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u attachmentIDs := make([]string, 0, len(content.Attachments)) if setting.Attachment.Enabled { for _, attachment := range content.Attachments { - a, err := attachment_service.UploadAttachment(bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{ + a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{ Name: attachment.Name, UploaderID: doer.ID, RepoID: issue.Repo.ID, @@ -130,6 +130,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u false, // not pending review but a single review comment.ReviewID, "", + nil, ) if err != nil { return fmt.Errorf("CreateCodeComment failed: %w", err) @@ -170,7 +171,7 @@ func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *u return nil } - return issues_model.CreateOrUpdateIssueWatch(doer.ID, issue.ID, false) + return issues_model.CreateOrUpdateIssueWatch(ctx, doer.ID, issue.ID, false) } return fmt.Errorf("unsupported unsubscribe reference: %v", ref) diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 50d59a44527..a63ba7a52af 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -26,7 +26,6 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" @@ -68,15 +67,12 @@ func SendTestMail(email string) error { func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) { locale := translation.NewLocale(language) data := map[string]any{ + "locale": locale, "DisplayName": u.DisplayName(), "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale), "Code": code, "Language": locale.Language(), - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var content bytes.Buffer @@ -98,7 +94,7 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { // No mail service configured return } - sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.activate_account"), "activate account") + sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account") } // SendResetPasswordMail sends a password reset mail to the user @@ -108,26 +104,23 @@ func SendResetPasswordMail(u *user_model.User) { return } locale := translation.NewLocale(u.Language) - sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.reset_password"), "recover account") + sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account") } // SendActivateEmailMail sends confirmation email to confirm new email address -func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) { +func SendActivateEmailMail(u *user_model.User, email string) { if setting.MailService == nil { // No mail service configured return } locale := translation.NewLocale(u.Language) data := map[string]any{ + "locale": locale, "DisplayName": u.DisplayName(), "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), - "Code": u.GenerateEmailActivateCode(email.Email), - "Email": email.Email, + "Code": u.GenerateEmailActivateCode(email), + "Email": email, "Language": locale.Language(), - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var content bytes.Buffer @@ -137,7 +130,7 @@ func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) { return } - msg := NewMessage(email.Email, locale.Tr("mail.activate_email"), content.String()) + msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String()) msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) SendAsync(msg) @@ -152,13 +145,10 @@ func SendRegisterNotifyMail(u *user_model.User) { locale := translation.NewLocale(u.Language) data := map[string]any{ + "locale": locale, "DisplayName": u.DisplayName(), "Username": u.Name, "Language": locale.Language(), - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var content bytes.Buffer @@ -168,7 +158,7 @@ func SendRegisterNotifyMail(u *user_model.User) { return } - msg := NewMessage(u.Email, locale.Tr("mail.register_notify"), content.String()) + msg := NewMessage(u.Email, locale.TrString("mail.register_notify"), content.String()) msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID) SendAsync(msg) @@ -183,16 +173,13 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) locale := translation.NewLocale(u.Language) repoName := repo.FullName() - subject := locale.Tr("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName) + subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName) data := map[string]any{ + "locale": locale, "Subject": subject, "RepoName": repoName, "Link": repo.HTMLURL(), "Language": locale.Language(), - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var content bytes.Buffer @@ -233,9 +220,12 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient // This is the body of the new issue or comment, not the mail body body, err := markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: ctx.Issue.Repo.HTMLURL(), - Metas: ctx.Issue.Repo.ComposeMetas(), + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: ctx.Issue.Repo.HTMLURL(), + }, + Metas: ctx.Issue.Repo.ComposeMetas(ctx), }, ctx.Content) if err != nil { return nil, err @@ -259,6 +249,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient locale := translation.NewLocale(lang) mailMeta := map[string]any{ + "locale": locale, "FallbackSubject": fallback, "Body": body, "Link": link, @@ -275,10 +266,6 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient "ReviewComments": reviewComments, "Language": locale.Language(), "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush, - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var mailSubject bytes.Buffer @@ -306,8 +293,10 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient reference := createReference(ctx.Issue, nil, activities_model.ActionType(0)) var replyPayload []byte - if ctx.Comment != nil && ctx.Comment.Type == issues_model.CommentTypeCode { - replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment) + if ctx.Comment != nil { + if ctx.Comment.Type.HasMailReplySupport() { + replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment) + } } else { replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue) } @@ -322,7 +311,13 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient msgs := make([]*Message, 0, len(recipients)) for _, recipient := range recipients { - msg := NewMessageFrom(recipient.Email, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) + msg := NewMessageFrom( + recipient.Email, + ctx.Doer.GetCompleteName(), + setting.MailService.FromEmail, + subject, + mailBody.String(), + ) msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) msg.SetHeader("Message-ID", msgID) @@ -332,7 +327,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"} if setting.IncomingEmail.Enabled { - if ctx.Comment != nil { + if replyPayload != nil { token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload) if err != nil { log.Error("CreateToken failed: %v", err) @@ -406,8 +401,8 @@ func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient "X-Mailer": "Gitea", "X-Gitea-Reason": reason, - "X-Gitea-Sender": ctx.Doer.DisplayName(), - "X-Gitea-Recipient": recipient.DisplayName(), + "X-Gitea-Sender": ctx.Doer.Name, + "X-Gitea-Recipient": recipient.Name, "X-Gitea-Recipient-Address": recipient.Email, "X-Gitea-Repository": repo.Name, "X-Gitea-Repository-Path": repo.FullName(), @@ -416,8 +411,8 @@ func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(), "X-GitHub-Reason": reason, - "X-GitHub-Sender": ctx.Doer.DisplayName(), - "X-GitHub-Recipient": recipient.DisplayName(), + "X-GitHub-Sender": ctx.Doer.Name, + "X-GitHub-Recipient": recipient.Name, "X-GitHub-Recipient-Address": recipient.Email, "X-GitLab-NotificationReason": reason, @@ -469,7 +464,7 @@ func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer if err != nil { return err } - SendAsyncs(msgs) + SendAsync(msgs...) } return nil } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index be5279aac5c..fab3315be21 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -82,7 +82,7 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo // =========== Repo watchers =========== // Make repo watchers last, since it's likely the list with the most users - if !(ctx.Issue.IsPull && ctx.Issue.PullRequest.IsWorkInProgress() && ctx.ActionType != activities_model.ActionCreatePullRequest) { + if !(ctx.Issue.IsPull && ctx.Issue.PullRequest.IsWorkInProgress(ctx) && ctx.ActionType != activities_model.ActionCreatePullRequest) { ids, err = repo_model.GetRepoWatchersIDs(ctx, ctx.Issue.RepoID) if err != nil { return fmt.Errorf("GetRepoWatchersIDs(%d): %w", ctx.Issue.RepoID, err) @@ -162,7 +162,7 @@ func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, vi if err != nil { return err } - SendAsyncs(msgs) + SendAsync(msgs...) receivers = receivers[:i] } } diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index fb638ebd42c..6682774a04b 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -14,7 +14,6 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" ) @@ -58,24 +57,24 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo var err error rel.RenderedNote, err = markdown.RenderString(&markup.RenderContext{ - Ctx: ctx, - URLPrefix: rel.Repo.Link(), - Metas: rel.Repo.ComposeMetas(), + Ctx: ctx, + Links: markup.Links{ + Base: rel.Repo.HTMLURL(), + }, + Metas: rel.Repo.ComposeMetas(ctx), }, rel.Note) if err != nil { log.Error("markdown.RenderString(%d): %v", rel.RepoID, err) return } - subject := locale.Tr("mail.release.new.subject", rel.TagName, rel.Repo.FullName()) + subject := locale.TrString("mail.release.new.subject", rel.TagName, rel.Repo.FullName()) mailMeta := map[string]any{ + "locale": locale, "Release": rel, "Subject": subject, "Language": locale.Language(), - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, + "Link": rel.HTMLURL(), } var mailBody bytes.Buffer @@ -95,5 +94,5 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo msgs = append(msgs, msg) } - SendAsyncs(msgs) + SendAsync(msgs...) } diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go index e9c1991b5b8..e0d55bb1200 100644 --- a/services/mailer/mail_repo.go +++ b/services/mailer/mail_repo.go @@ -12,7 +12,6 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" ) @@ -57,14 +56,15 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U content bytes.Buffer ) - destination := locale.Tr("mail.repo.transfer.to_you") - subject := locale.Tr("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName()) + destination := locale.TrString("mail.repo.transfer.to_you") + subject := locale.TrString("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName()) if newOwner.IsOrganization() { destination = newOwner.DisplayName() - subject = locale.Tr("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination) + subject = locale.TrString("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination) } data := map[string]any{ + "locale": locale, "Doer": doer, "User": repo.Owner, "Repo": repo.FullName(), @@ -72,10 +72,6 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U "Subject": subject, "Language": locale.Language(), "Destination": destination, - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil { diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go index 88ad0c98365..ceecefa50fa 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -14,7 +14,6 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" ) @@ -51,18 +50,15 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect) } - subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) + subject := locale.TrString("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) mailMeta := map[string]any{ + "locale": locale, "Inviter": inviter, "Organization": org, "Team": team, "Invite": invite, "Subject": subject, "InviteURL": inviteURL, - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, } var mailBody bytes.Buffer diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 64f2f740ca2..d87c57ffe75 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "html/template" + "io" + "mime/quotedprintable" "regexp" "strings" "testing" @@ -19,6 +21,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -67,6 +70,12 @@ func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Re func TestComposeIssueCommentMessage(t *testing.T) { doer, _, issue, comment := prepareMailerTest(t) + markup.Init(&markup.ProcessorHelper{ + IsUsernameMentionable: func(ctx context.Context, username string) bool { + return username == doer.Name + }, + }) + setting.IncomingEmail.Enabled = true defer func() { setting.IncomingEmail.Enabled = false }() @@ -77,7 +86,8 @@ func TestComposeIssueCommentMessage(t *testing.T) { msgs, err := composeIssueCommentMessages(&mailCommentContext{ Context: context.TODO(), // TODO: use a correct context Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue, - Content: "test body", Comment: comment, + Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index), + Comment: comment, }, "en-US", recipients, false, "issue comment") assert.NoError(t, err) assert.Len(t, msgs, 2) @@ -96,6 +106,20 @@ func TestComposeIssueCommentMessage(t *testing.T) { assert.Equal(t, "", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match") assert.Equal(t, "", gomailMsg.GetHeader("List-Post")[0]) assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto + + var buf bytes.Buffer + gomailMsg.WriteTo(&buf) + + b, err := io.ReadAll(quotedprintable.NewReader(&buf)) + assert.NoError(t, err) + + // text/plain + assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, doer.HTMLURL())) + assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, issue.HTMLURL())) + + // text/html + assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, doer.HTMLURL())) + assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL())) } func TestComposeIssueMessage(t *testing.T) { @@ -239,7 +263,7 @@ func TestGenerateAdditionalHeaders(t *testing.T) { doer, _, issue, _ := prepareMailerTest(t) ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer} - recipient := &user_model.User{Name: "Test", Email: "test@gitea.com"} + recipient := &user_model.User{Name: "test", Email: "test@gitea.com"} headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient) @@ -247,8 +271,8 @@ func TestGenerateAdditionalHeaders(t *testing.T) { "List-ID": "user2/repo1 ", "List-Archive": "", "X-Gitea-Reason": "dummy-reason", - "X-Gitea-Sender": "< Ur Tw ><", - "X-Gitea-Recipient": "Test", + "X-Gitea-Sender": "user2", + "X-Gitea-Recipient": "test", "X-Gitea-Recipient-Address": "test@gitea.com", "X-Gitea-Repository": "repo1", "X-Gitea-Repository-Path": "user2/repo1", diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go index 6d3f1d9e015..5e8e3dbb38c 100644 --- a/services/mailer/mailer.go +++ b/services/mailer/mailer.go @@ -361,9 +361,8 @@ func (s *sendmailSender) Send(from string, to []string, msg io.WriterTo) error { return err } else if closeError != nil { return closeError - } else { - return waitError } + return waitError } // Sender sendmail mail sender @@ -426,15 +425,12 @@ func NewContext(ctx context.Context) { go graceful.GetManager().RunWithCancel(mailQueue) } -// SendAsync send mail asynchronously -func SendAsync(msg *Message) { - SendAsyncs([]*Message{msg}) -} +// SendAsync send emails asynchronously (make it mockable) +var SendAsync = sendAsync -// SendAsyncs send mails asynchronously -func SendAsyncs(msgs []*Message) { +func sendAsync(msgs ...*Message) { if setting.MailService == nil { - log.Error("Mailer: SendAsyncs is being invoked but mail service hasn't been initialized") + log.Error("Mailer: SendAsync is being invoked but mail service hasn't been initialized") return } diff --git a/services/mailer/main_test.go b/services/mailer/main_test.go index e906f4cb6e1..f803c736ca1 100644 --- a/services/mailer/main_test.go +++ b/services/mailer/main_test.go @@ -4,7 +4,6 @@ package mailer import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -13,7 +12,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/mailer/notify.go b/services/mailer/notify.go index 9eaf268d0ac..e48b5d399d9 100644 --- a/services/mailer/notify.go +++ b/services/mailer/notify.go @@ -79,7 +79,7 @@ func (m *mailNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.Us log.Error("issue.LoadPullRequest: %v", err) return } - if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress() { + if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) { if err := MailParticipants(ctx, issue, doer, activities_model.ActionPullRequestReadyForReview, nil); err != nil { log.Error("MailParticipants: %v", err) } @@ -114,7 +114,7 @@ func (m *mailNotifier) PullRequestCodeComment(ctx context.Context, pr *issues_mo func (m *mailNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { // mail only sent to added assignees and not self-assignee - if !removed && doer.ID != assignee.ID && assignee.EmailNotifications() != user_model.EmailNotificationsDisabled { + if !removed && doer.ID != assignee.ID && assignee.EmailNotificationsPreference != user_model.EmailNotificationsDisabled { ct := fmt.Sprintf("Assigned #%d.", issue.Index) if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{assignee}); err != nil { log.Error("Error in SendIssueAssignedMail for issue[%d] to assignee[%d]: %v", issue.ID, assignee.ID, err) @@ -123,7 +123,7 @@ func (m *mailNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model } func (m *mailNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { - if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotifications() != user_model.EmailNotificationsDisabled { + if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotificationsPreference != user_model.EmailNotificationsDisabled { ct := fmt.Sprintf("Requested to review %s.", issue.HTMLURL()) if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{reviewer}); err != nil { log.Error("Error in SendIssueAssignedMail for issue[%d] to reviewer[%d]: %v", issue.ID, reviewer.ID, err) diff --git a/services/mailer/token/token.go b/services/mailer/token/token.go index aa7b5671886..8a5a762d6b5 100644 --- a/services/mailer/token/token.go +++ b/services/mailer/token/token.go @@ -6,14 +6,13 @@ package token import ( "context" crypto_hmac "crypto/hmac" + "crypto/sha256" "encoding/base32" "fmt" "time" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/util" - - "github.com/minio/sha256-simd" ) // A token is a verifiable container describing an action. diff --git a/services/markup/main_test.go b/services/markup/main_test.go index ce892435a1a..5553ebc0589 100644 --- a/services/markup/main_test.go +++ b/services/markup/main_test.go @@ -4,7 +4,6 @@ package markup import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -12,7 +11,6 @@ import ( func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - FixtureFiles: []string{"user.yml"}, + FixtureFiles: []string{"user.yml", "repository.yml", "access.yml", "repo_unit.yml"}, }) } diff --git a/services/markup/processorhelper.go b/services/markup/processorhelper.go index 3551f85c466..68487fb8dbb 100644 --- a/services/markup/processorhelper.go +++ b/services/markup/processorhelper.go @@ -7,13 +7,15 @@ import ( "context" "code.gitea.io/gitea/models/user" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/markup" + gitea_context "code.gitea.io/gitea/services/context" ) func ProcessorHelper() *markup.ProcessorHelper { return &markup.ProcessorHelper{ ElementDir: "auto", // set dir="auto" for necessary (eg:

, , etc) tags + + RenderRepoFileCodePreview: renderRepoFileCodePreview, IsUsernameMentionable: func(ctx context.Context, username string) bool { mentionedUser, err := user.GetUserByName(ctx, username) if err != nil { diff --git a/services/markup/processorhelper_codepreview.go b/services/markup/processorhelper_codepreview.go new file mode 100644 index 00000000000..ef95046128d --- /dev/null +++ b/services/markup/processorhelper_codepreview.go @@ -0,0 +1,117 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "bufio" + "context" + "fmt" + "html/template" + "strings" + + "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/repository/files" +) + +func renderRepoFileCodePreview(ctx context.Context, opts markup.RenderCodePreviewOptions) (template.HTML, error) { + opts.LineStop = max(opts.LineStop, opts.LineStart) + lineCount := opts.LineStop - opts.LineStart + 1 + if lineCount <= 0 || lineCount > 140 /* GitHub at most show 140 lines */ { + lineCount = 10 + opts.LineStop = opts.LineStart + lineCount + } + + dbRepo, err := repo.GetRepositoryByOwnerAndName(ctx, opts.OwnerName, opts.RepoName) + if err != nil { + return "", err + } + + webCtx, ok := ctx.Value(gitea_context.WebContextKey).(*gitea_context.Context) + if !ok { + return "", fmt.Errorf("context is not a web context") + } + doer := webCtx.Doer + + perms, err := access.GetUserRepoPermission(ctx, dbRepo, doer) + if err != nil { + return "", err + } + if !perms.CanRead(unit.TypeCode) { + return "", fmt.Errorf("no permission") + } + + gitRepo, err := gitrepo.OpenRepository(ctx, dbRepo) + if err != nil { + return "", err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(opts.CommitID) + if err != nil { + return "", err + } + + language, _ := files.TryGetContentLanguage(gitRepo, opts.CommitID, opts.FilePath) + blob, err := commit.GetBlobByPath(opts.FilePath) + if err != nil { + return "", err + } + + if blob.Size() > setting.UI.MaxDisplayFileSize { + return "", fmt.Errorf("file is too large") + } + + dataRc, err := blob.DataAsync() + if err != nil { + return "", err + } + defer dataRc.Close() + + reader := bufio.NewReader(dataRc) + for i := 1; i < opts.LineStart; i++ { + if _, err = reader.ReadBytes('\n'); err != nil { + return "", err + } + } + + lineNums := make([]int, 0, lineCount) + lineCodes := make([]string, 0, lineCount) + for i := opts.LineStart; i <= opts.LineStop; i++ { + if line, err := reader.ReadString('\n'); err != nil && line == "" { + break + } else { + lineNums = append(lineNums, i) + lineCodes = append(lineCodes, line) + } + } + realLineStop := max(opts.LineStart, opts.LineStart+len(lineNums)-1) + highlightLines := code.HighlightSearchResultCode(opts.FilePath, language, lineNums, strings.Join(lineCodes, "")) + + escapeStatus := &charset.EscapeStatus{} + lineEscapeStatus := make([]*charset.EscapeStatus, len(highlightLines)) + for i, hl := range highlightLines { + lineEscapeStatus[i], hl.FormattedContent = charset.EscapeControlHTML(hl.FormattedContent, webCtx.Base.Locale, charset.RuneNBSP) + escapeStatus = escapeStatus.Or(lineEscapeStatus[i]) + } + + return webCtx.RenderToHTML("base/markup_codepreview", map[string]any{ + "FullURL": opts.FullURL, + "FilePath": opts.FilePath, + "LineStart": opts.LineStart, + "LineStop": realLineStop, + "RepoLink": dbRepo.Link(), + "CommitID": opts.CommitID, + "HighlightLines": highlightLines, + "EscapeStatus": escapeStatus, + "LineEscapeStatus": lineEscapeStatus, + }) +} diff --git a/services/markup/processorhelper_codepreview_test.go b/services/markup/processorhelper_codepreview_test.go new file mode 100644 index 00000000000..154e4e8e441 --- /dev/null +++ b/services/markup/processorhelper_codepreview_test.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestProcessorHelperCodePreview(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx, _ := contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + htm, err := renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user2", + RepoName: "repo1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + LineStop: 2, + }) + assert.NoError(t, err) + assert.Equal(t, `

+
+ /README.md + repo.code_preview_line_from_to:1,2,65f1bf27bc +
+ + + + + + + + +
# repo1
+
+`, string(htm)) + + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + htm, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user2", + RepoName: "repo1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + }) + assert.NoError(t, err) + assert.Equal(t, `
+
+ /README.md + repo.code_preview_line_in:1,65f1bf27bc +
+ + + + + +
# repo1
+
+`, string(htm)) + + ctx, _ = contexttest.MockContext(t, "/", contexttest.MockContextOption{Render: templates.HTMLRenderer()}) + _, err = renderRepoFileCodePreview(ctx, markup.RenderCodePreviewOptions{ + FullURL: "http://full", + OwnerName: "user15", + RepoName: "big_test_private_1", + CommitID: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + FilePath: "/README.md", + LineStart: 1, + LineStop: 10, + }) + assert.ErrorContains(t, err, "no permission") +} diff --git a/services/markup/processorhelper_test.go b/services/markup/processorhelper_test.go index ef8f5622451..170edae0e0b 100644 --- a/services/markup/processorhelper_test.go +++ b/services/markup/processorhelper_test.go @@ -12,8 +12,8 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/user" - gitea_context "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/contexttest" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) diff --git a/services/migrations/common.go b/services/migrations/common.go index 4f9837472d9..d88518899d0 100644 --- a/services/migrations/common.go +++ b/services/migrations/common.go @@ -48,16 +48,18 @@ func CheckAndEnsureSafePR(pr *base.PullRequest, commonCloneBaseURL string, g bas } // SECURITY: SHAs Must be a SHA - if pr.MergeCommitSHA != "" && !git.IsValidSHAPattern(pr.MergeCommitSHA) { + // FIXME: hash only a SHA1 + CommitType := git.Sha1ObjectFormat + if pr.MergeCommitSHA != "" && !CommitType.IsValid(pr.MergeCommitSHA) { WarnAndNotice("PR #%d in %s has invalid MergeCommitSHA: %s", pr.Number, g, pr.MergeCommitSHA) pr.MergeCommitSHA = "" } - if pr.Head.SHA != "" && !git.IsValidSHAPattern(pr.Head.SHA) { + if pr.Head.SHA != "" && !CommitType.IsValid(pr.Head.SHA) { WarnAndNotice("PR #%d in %s has invalid HeadSHA: %s", pr.Number, g, pr.Head.SHA) pr.Head.SHA = "" valid = false } - if pr.Base.SHA != "" && !git.IsValidSHAPattern(pr.Base.SHA) { + if pr.Base.SHA != "" && !CommitType.IsValid(pr.Base.SHA) { WarnAndNotice("PR #%d in %s has invalid BaseSHA: %s", pr.Number, g, pr.Base.SHA) pr.Base.SHA = "" valid = false diff --git a/services/migrations/dump.go b/services/migrations/dump.go index 603954810cf..07812002afa 100644 --- a/services/migrations/dump.go +++ b/services/migrations/dump.go @@ -655,7 +655,7 @@ func DumpRepository(ctx context.Context, baseDir, ownerName string, opts base.Mi return err } - if err := migrateRepository(doer, downloader, uploader, opts, nil); err != nil { + if err := migrateRepository(ctx, doer, downloader, uploader, opts, nil); err != nil { if err1 := uploader.Rollback(); err1 != nil { log.Error("rollback failed: %v", err1) } @@ -727,7 +727,7 @@ func RestoreRepository(ctx context.Context, baseDir, ownerName, repoName string, return err } - if err = migrateRepository(doer, downloader, uploader, migrateOpts, nil); err != nil { + if err = migrateRepository(ctx, doer, downloader, uploader, migrateOpts, nil); err != nil { if err1 := uploader.Rollback(); err1 != nil { log.Error("rollback failed: %v", err1) } diff --git a/services/migrations/error.go b/services/migrations/error.go index c8e6c8fe13d..5e0e0742c92 100644 --- a/services/migrations/error.go +++ b/services/migrations/error.go @@ -7,7 +7,7 @@ package migrations import ( "errors" - "github.com/google/go-github/v53/github" + "github.com/google/go-github/v57/github" ) // ErrRepoNotCreated returns the error that repository not created diff --git a/services/migrations/gitea_downloader.go b/services/migrations/gitea_downloader.go index b9ba93325b9..d402a238f27 100644 --- a/services/migrations/gitea_downloader.go +++ b/services/migrations/gitea_downloader.go @@ -282,6 +282,8 @@ func (g *GiteaDownloader) convertGiteaRelease(rel *gitea_sdk.Release) *base.Rele httpClient := NewMigrationHTTPClient() for _, asset := range rel.Attachments { + assetID := asset.ID // Don't optimize this, for closure we need a local variable + assetDownloadURL := asset.DownloadURL size := int(asset.Size) dlCount := int(asset.DownloadCount) r.Assets = append(r.Assets, &base.ReleaseAsset{ @@ -292,18 +294,18 @@ func (g *GiteaDownloader) convertGiteaRelease(rel *gitea_sdk.Release) *base.Rele Created: asset.Created, DownloadURL: &asset.DownloadURL, DownloadFunc: func() (io.ReadCloser, error) { - asset, _, err := g.client.GetReleaseAttachment(g.repoOwner, g.repoName, rel.ID, asset.ID) + asset, _, err := g.client.GetReleaseAttachment(g.repoOwner, g.repoName, rel.ID, assetID) if err != nil { return nil, err } - if !hasBaseURL(asset.DownloadURL, g.baseURL) { - WarnAndNotice("Unexpected AssetURL for assetID[%d] in %s: %s", asset.ID, g, asset.DownloadURL) + if !hasBaseURL(assetDownloadURL, g.baseURL) { + WarnAndNotice("Unexpected AssetURL for assetID[%d] in %s: %s", assetID, g, assetDownloadURL) return io.NopCloser(strings.NewReader(asset.DownloadURL)), nil } // FIXME: for a private download? - req, err := http.NewRequest("GET", asset.DownloadURL, nil) + req, err := http.NewRequest("GET", assetDownloadURL, nil) if err != nil { return nil, err } diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index 4c21efae44f..87691bf7296 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -19,7 +19,9 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + base_module "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" @@ -118,7 +120,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate r.DefaultBranch = repo.DefaultBranch r.Description = repo.Description - r, err = repo_module.MigrateRepositoryGitData(g.ctx, owner, r, base.MigrateOptions{ + r, err = repo_service.MigrateRepositoryGitData(g.ctx, owner, r, base.MigrateOptions{ RepoName: g.repoName, Description: repo.Description, OriginalURL: repo.OriginalURL, @@ -138,8 +140,18 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate if err != nil { return err } - g.gitRepo, err = git.OpenRepository(g.ctx, r.RepoPath()) - return err + g.gitRepo, err = gitrepo.OpenRepository(g.ctx, g.repo) + if err != nil { + return err + } + + // detect object format from git repository and update to database + objectFormat, err := g.gitRepo.GetObjectFormat() + if err != nil { + return err + } + g.repo.ObjectFormatName = objectFormat.Name() + return repo_model.UpdateRepositoryCols(g.ctx, g.repo, "object_format_name") } // Close closes this uploader @@ -162,7 +174,7 @@ func (g *GiteaLocalUploader) CreateTopics(topics ...string) error { c++ } topics = topics[:c] - return repo_model.SaveTopics(g.repo.ID, topics...) + return repo_model.SaveTopics(g.ctx, g.repo.ID, topics...) } // CreateMilestones creates milestones @@ -205,7 +217,7 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err mss = append(mss, &ms) } - err := issues_model.InsertMilestones(mss...) + err := issues_model.InsertMilestones(g.ctx, mss...) if err != nil { return err } @@ -236,7 +248,7 @@ func (g *GiteaLocalUploader) CreateLabels(labels ...*base.Label) error { }) } - err := issues_model.NewLabels(lbs...) + err := issues_model.NewLabels(g.ctx, lbs...) if err != nil { return err } @@ -350,12 +362,12 @@ func (g *GiteaLocalUploader) CreateReleases(releases ...*base.Release) error { rels = append(rels, &rel) } - return repo_model.InsertReleases(rels...) + return repo_model.InsertReleases(g.ctx, rels...) } // SyncTags syncs releases with tags in the database func (g *GiteaLocalUploader) SyncTags() error { - return repo_module.SyncReleasesWithTags(g.repo, g.gitRepo) + return repo_module.SyncReleasesWithTags(g.ctx, g.repo, g.gitRepo) } // CreateIssues creates issues @@ -397,7 +409,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error { RepoID: g.repo.ID, Repo: g.repo, Index: issue.Number, - Title: issue.Title, + Title: base_module.TruncateString(issue.Title, 255), Content: issue.Content, Ref: issue.Ref, IsClosed: issue.State == "closed", @@ -430,7 +442,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error { } if len(iss) > 0 { - if err := issues_model.InsertIssues(iss...); err != nil { + if err := issues_model.InsertIssues(g.ctx, iss...); err != nil { return err } @@ -471,6 +483,10 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error { } switch cm.Type { + case issues_model.CommentTypeReopen: + cm.Content = "" + case issues_model.CommentTypeClose: + cm.Content = "" case issues_model.CommentTypeAssignees: if assigneeID, ok := comment.Meta["AssigneeID"].(int); ok { cm.AssigneeID = int64(assigneeID) @@ -480,11 +496,21 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error { } case issues_model.CommentTypeChangeTitle: if comment.Meta["OldTitle"] != nil { - cm.OldTitle = fmt.Sprintf("%s", comment.Meta["OldTitle"]) + cm.OldTitle = fmt.Sprint(comment.Meta["OldTitle"]) } if comment.Meta["NewTitle"] != nil { - cm.NewTitle = fmt.Sprintf("%s", comment.Meta["NewTitle"]) + cm.NewTitle = fmt.Sprint(comment.Meta["NewTitle"]) } + case issues_model.CommentTypeChangeTargetBranch: + if comment.Meta["OldRef"] != nil && comment.Meta["NewRef"] != nil { + cm.OldRef = fmt.Sprint(comment.Meta["OldRef"]) + cm.NewRef = fmt.Sprint(comment.Meta["NewRef"]) + cm.Content = "" + } + case issues_model.CommentTypeMergePull: + cm.Content = "" + case issues_model.CommentTypePRScheduledToAutoMerge, issues_model.CommentTypePRUnScheduledToAutoMerge: + cm.Content = "" default: } @@ -510,13 +536,12 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error { if len(cms) == 0 { return nil } - return issues_model.InsertIssueComments(cms) + return issues_model.InsertIssueComments(g.ctx, cms) } // CreatePullRequests creates pull requests func (g *GiteaLocalUploader) CreatePullRequests(prs ...*base.PullRequest) error { gprs := make([]*issues_model.PullRequest, 0, len(prs)) - ctx := db.DefaultContext for _, pr := range prs { gpr, err := g.newPullRequest(pr) if err != nil { @@ -529,12 +554,12 @@ func (g *GiteaLocalUploader) CreatePullRequests(prs ...*base.PullRequest) error gprs = append(gprs, gpr) } - if err := issues_model.InsertPullRequests(ctx, gprs...); err != nil { + if err := issues_model.InsertPullRequests(g.ctx, gprs...); err != nil { return err } for _, pr := range gprs { g.issues[pr.Issue.Index] = pr.Issue - pull.AddToTaskQueue(ctx, pr) + pull.AddToTaskQueue(g.ctx, pr) } return nil } @@ -841,7 +866,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error { pr, ok := g.prCache[issue.ID] if !ok { var err error - pr, err = issues_model.GetPullRequestByIssueIDWithNoAttributes(issue.ID) + pr, err = issues_model.GetPullRequestByIssueIDWithNoAttributes(g.ctx, issue.ID) if err != nil { return err } @@ -863,7 +888,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error { line := comment.Line if line != 0 { comment.Position = 1 - } else { + } else if comment.DiffHunk != "" { _, _, line, _ = git.ParseDiffHunkString(comment.DiffHunk) } @@ -893,7 +918,8 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error { comment.UpdatedAt = comment.CreatedAt } - if !git.IsValidSHAPattern(comment.CommitID) { + objectFormat := git.ObjectFormatFromName(g.repo.ObjectFormatName) + if !objectFormat.IsValid(comment.CommitID) { log.Warn("Invalid comment CommitID[%s] on comment[%d] in PR #%d of %s/%s replaced with %s", comment.CommitID, pr.Index, g.repoOwner, g.repoName, headCommitID) comment.CommitID = headCommitID } @@ -918,7 +944,7 @@ func (g *GiteaLocalUploader) CreateReviews(reviews ...*base.Review) error { } } - return issues_model.InsertReviews(cms) + return issues_model.InsertReviews(g.ctx, cms) } // Rollback when migrating failed, this will rollback all the changes. @@ -938,7 +964,7 @@ func (g *GiteaLocalUploader) Finish() error { } // update issue_index - if err := issues_model.RecalculateIssueIndexForRepo(g.repo.ID); err != nil { + if err := issues_model.RecalculateIssueIndexForRepo(g.ctx, g.repo.ID); err != nil { return err } @@ -990,7 +1016,7 @@ func (g *GiteaLocalUploader) remapLocalUser(source user_model.ExternalUserMigrat func (g *GiteaLocalUploader) remapExternalUser(source user_model.ExternalUserMigrated, target user_model.ExternalUserRemappable) (userid int64, err error) { userid, ok := g.userMap[source.GetExternalID()] if !ok { - userid, err = user_model.GetUserIDByExternalUserID(g.gitServiceType.Name(), fmt.Sprintf("%d", source.GetExternalID())) + userid, err = user_model.GetUserIDByExternalUserID(g.ctx, g.gitServiceType.Name(), fmt.Sprintf("%d", source.GetExternalID())) if err != nil { log.Error("GetUserIDByExternalUserID: %v", err) return 0, err diff --git a/services/migrations/gitea_uploader_test.go b/services/migrations/gitea_uploader_test.go index e42d9e92861..c9b92480981 100644 --- a/services/migrations/gitea_uploader_test.go +++ b/services/migrations/gitea_uploader_test.go @@ -19,12 +19,13 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" - "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) @@ -44,7 +45,7 @@ func TestGiteaUploadRepo(t *testing.T) { uploader = NewGiteaLocalUploader(graceful.GetManager().HammerContext(), user, user.Name, repoName) ) - err := migrateRepository(user, downloader, uploader, base.MigrateOptions{ + err := migrateRepository(db.DefaultContext, user, downloader, uploader, base.MigrateOptions{ CloneAddr: "https://github.com/go-xorm/builder", RepoName: repoName, AuthUsername: "", @@ -65,16 +66,16 @@ func TestGiteaUploadRepo(t *testing.T) { assert.True(t, repo.HasWiki()) assert.EqualValues(t, repo_model.RepositoryReady, repo.Status) - milestones, _, err := issues_model.GetMilestones(issues_model.GetMilestonesOption{ - RepoID: repo.ID, - State: structs.StateOpen, + milestones, err := db.Find[issues_model.Milestone](db.DefaultContext, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(false), }) assert.NoError(t, err) assert.Len(t, milestones, 1) - milestones, _, err = issues_model.GetMilestones(issues_model.GetMilestonesOption{ - RepoID: repo.ID, - State: structs.StateClosed, + milestones, err = db.Find[issues_model.Milestone](db.DefaultContext, issues_model.FindMilestoneOptions{ + RepoID: repo.ID, + IsClosed: optional.Some(true), }) assert.NoError(t, err) assert.Empty(t, milestones) @@ -83,29 +84,31 @@ func TestGiteaUploadRepo(t *testing.T) { assert.NoError(t, err) assert.Len(t, labels, 12) - releases, err := repo_model.GetReleasesByRepoID(db.DefaultContext, repo.ID, repo_model.FindReleasesOptions{ + releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{ ListOptions: db.ListOptions{ PageSize: 10, Page: 0, }, IncludeTags: true, + RepoID: repo.ID, }) assert.NoError(t, err) assert.Len(t, releases, 8) - releases, err = repo_model.GetReleasesByRepoID(db.DefaultContext, repo.ID, repo_model.FindReleasesOptions{ + releases, err = db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{ ListOptions: db.ListOptions{ PageSize: 10, Page: 0, }, IncludeTags: false, + RepoID: repo.ID, }) assert.NoError(t, err) assert.Len(t, releases, 1) issues, err := issues_model.Issues(db.DefaultContext, &issues_model.IssuesOptions{ RepoIDs: []int64{repo.ID}, - IsPull: util.OptionalBoolFalse, + IsPull: optional.Some(false), SortType: "oldest", }) assert.NoError(t, err) @@ -210,7 +213,7 @@ func TestGiteaUploadRemapExternalUser(t *testing.T) { LoginSourceID: 0, Provider: structs.GiteaService.Name(), } - err = user_model.LinkExternalToUser(linkedUser, externalLoginUser) + err = user_model.LinkExternalToUser(db.DefaultContext, linkedUser, externalLoginUser) assert.NoError(t, err) // @@ -232,7 +235,7 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) { // fromRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) baseRef := "master" - assert.NoError(t, git.InitRepository(git.DefaultContext, fromRepo.RepoPath(), false)) + assert.NoError(t, git.InitRepository(git.DefaultContext, fromRepo.RepoPath(), false, fromRepo.ObjectFormatName)) err := git.NewCommand(git.DefaultContext, "symbolic-ref").AddDynamicArguments("HEAD", git.BranchPrefix+baseRef).Run(&git.RunOpts{Dir: fromRepo.RepoPath()}) assert.NoError(t, err) assert.NoError(t, os.WriteFile(filepath.Join(fromRepo.RepoPath(), "README.md"), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s", fromRepo.RepoPath())), 0o644)) @@ -247,7 +250,7 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) { Author: &signature, Message: "Initial Commit", })) - fromGitRepo, err := git.OpenRepository(git.DefaultContext, fromRepo.RepoPath()) + fromGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, fromRepo) assert.NoError(t, err) defer fromGitRepo.Close() baseSHA, err := fromGitRepo.GetBranchCommitID(baseRef) @@ -290,7 +293,7 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) { Author: &signature, Message: "branch2 commit", })) - forkGitRepo, err := git.OpenRepository(git.DefaultContext, forkRepo.RepoPath()) + forkGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, forkRepo) assert.NoError(t, err) defer forkGitRepo.Close() forkHeadSHA, err := forkGitRepo.GetBranchCommitID(forkHeadRef) diff --git a/services/migrations/github.go b/services/migrations/github.go index f27c1a34daf..be573b33b3f 100644 --- a/services/migrations/github.go +++ b/services/migrations/github.go @@ -20,7 +20,7 @@ import ( "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/structs" - "github.com/google/go-github/v53/github" + "github.com/google/go-github/v57/github" "golang.org/x/oauth2" ) @@ -135,7 +135,7 @@ func (g *GithubDownloaderV3) LogString() string { func (g *GithubDownloaderV3) addClient(client *http.Client, baseURL string) { githubClient := github.NewClient(client) if baseURL != "https://github.com" { - githubClient, _ = github.NewEnterpriseClient(baseURL, baseURL, client) + githubClient, _ = github.NewClient(client).WithEnterpriseURLs(baseURL, baseURL) } g.clients = append(g.clients, githubClient) g.rates = append(g.rates, nil) @@ -168,14 +168,14 @@ func (g *GithubDownloaderV3) waitAndPickClient() { err := g.RefreshRate() if err != nil { - log.Error("g.getClient().RateLimits: %s", err) + log.Error("g.getClient().RateLimit.Get: %s", err) } } } // RefreshRate update the current rate (doesn't count in rate limit) func (g *GithubDownloaderV3) RefreshRate() error { - rates, _, err := g.getClient().RateLimits(g.ctx) + rates, _, err := g.getClient().RateLimit.Get(g.ctx) if err != nil { // if rate limit is not enabled, ignore it if strings.Contains(err.Error(), "404") { diff --git a/services/migrations/gitlab.go b/services/migrations/gitlab.go index f626036254e..bbc44e958ad 100644 --- a/services/migrations/gitlab.go +++ b/services/migrations/gitlab.go @@ -11,9 +11,11 @@ import ( "net/http" "net/url" "path" + "regexp" "strings" "time" + issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" @@ -55,19 +57,36 @@ func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType { return structs.GitlabService } +type gitlabIIDResolver struct { + maxIssueIID int64 + frozen bool +} + +func (r *gitlabIIDResolver) recordIssueIID(issueIID int) { + if r.frozen { + panic("cannot record issue IID after pull request IID generation has started") + } + r.maxIssueIID = max(r.maxIssueIID, int64(issueIID)) +} + +func (r *gitlabIIDResolver) generatePullRequestNumber(mrIID int) int64 { + r.frozen = true + return r.maxIssueIID + int64(mrIID) +} + // GitlabDownloader implements a Downloader interface to get repository information // from gitlab via go-gitlab // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap, // because Gitlab has individual Issue and Pull Request numbers. type GitlabDownloader struct { base.NullDownloader - ctx context.Context - client *gitlab.Client - baseURL string - repoID int - repoName string - issueCount int64 - maxPerPage int + ctx context.Context + client *gitlab.Client + baseURL string + repoID int + repoName string + iidResolver gitlabIIDResolver + maxPerPage int } // NewGitlabDownloader creates a gitlab Downloader via gitlab API @@ -310,6 +329,7 @@ func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Relea httpClient := NewMigrationHTTPClient() for k, asset := range rel.Assets.Links { + assetID := asset.ID // Don't optimize this, for closure we need a local variable r.Assets = append(r.Assets, &base.ReleaseAsset{ ID: int64(asset.ID), Name: asset.Name, @@ -317,13 +337,13 @@ func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Relea Size: &zero, DownloadCount: &zero, DownloadFunc: func() (io.ReadCloser, error) { - link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, asset.ID, gitlab.WithContext(g.ctx)) + link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, assetID, gitlab.WithContext(g.ctx)) if err != nil { return nil, err } if !hasBaseURL(link.URL, g.baseURL) { - WarnAndNotice("Unexpected AssetURL for assetID[%d] in %s: %s", asset.ID, g, link.URL) + WarnAndNotice("Unexpected AssetURL for assetID[%d] in %s: %s", assetID, g, link.URL) return io.NopCloser(strings.NewReader(link.URL)), nil } @@ -449,8 +469,8 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er Context: gitlabIssueContext{IsMergeRequest: false}, }) - // increment issueCount, to be used in GetPullRequests() - g.issueCount++ + // record the issue IID, to be used in GetPullRequests() + g.iidResolver.recordIssueIID(issue.IID) } return allIssues, len(issues) < perPage, nil @@ -488,51 +508,115 @@ func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Co return nil, false, fmt.Errorf("error while listing comments: %v %w", g.repoID, err) } for _, comment := range comments { - // Flatten comment threads - if !comment.IndividualNote { - for _, note := range comment.Notes { - allComments = append(allComments, &base.Comment{ - IssueIndex: commentable.GetLocalIndex(), - Index: int64(note.ID), - PosterID: int64(note.Author.ID), - PosterName: note.Author.Username, - PosterEmail: note.Author.Email, - Content: note.Body, - Created: *note.CreatedAt, - }) - } - } else { - c := comment.Notes[0] - allComments = append(allComments, &base.Comment{ - IssueIndex: commentable.GetLocalIndex(), - Index: int64(c.ID), - PosterID: int64(c.Author.ID), - PosterName: c.Author.Username, - PosterEmail: c.Author.Email, - Content: c.Body, - Created: *c.CreatedAt, - }) + for _, note := range comment.Notes { + allComments = append(allComments, g.convertNoteToComment(commentable.GetLocalIndex(), note)) + } + } + if resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + page = 1 + for { + var stateEvents []*gitlab.StateEvent + var resp *gitlab.Response + var err error + if context.IsMergeRequest { + stateEvents, resp, err = g.client.ResourceStateEvents.ListMergeStateEvents(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListStateEventsOptions{ + ListOptions: gitlab.ListOptions{ + Page: page, + PerPage: g.maxPerPage, + }, + }, nil, gitlab.WithContext(g.ctx)) + } else { + stateEvents, resp, err = g.client.ResourceStateEvents.ListIssueStateEvents(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListStateEventsOptions{ + ListOptions: gitlab.ListOptions{ + Page: page, + PerPage: g.maxPerPage, + }, + }, nil, gitlab.WithContext(g.ctx)) + } + if err != nil { + return nil, false, fmt.Errorf("error while listing state events: %v %w", g.repoID, err) + } + + for _, stateEvent := range stateEvents { + comment := &base.Comment{ + IssueIndex: commentable.GetLocalIndex(), + Index: int64(stateEvent.ID), + PosterID: int64(stateEvent.User.ID), + PosterName: stateEvent.User.Username, + Content: "", + Created: *stateEvent.CreatedAt, + } + switch stateEvent.State { + case gitlab.ClosedEventType: + comment.CommentType = issues_model.CommentTypeClose.String() + case gitlab.MergedEventType: + comment.CommentType = issues_model.CommentTypeMergePull.String() + case gitlab.ReopenedEventType: + comment.CommentType = issues_model.CommentTypeReopen.String() + default: + // Ignore other event types + continue } + allComments = append(allComments, comment) } + if resp.NextPage == 0 { break } page = resp.NextPage } + return allComments, true, nil } +var targetBranchChangeRegexp = regexp.MustCompile("^changed target branch from `(.*?)` to `(.*?)`$") + +func (g *GitlabDownloader) convertNoteToComment(localIndex int64, note *gitlab.Note) *base.Comment { + comment := &base.Comment{ + IssueIndex: localIndex, + Index: int64(note.ID), + PosterID: int64(note.Author.ID), + PosterName: note.Author.Username, + PosterEmail: note.Author.Email, + Content: note.Body, + Created: *note.CreatedAt, + Meta: map[string]any{}, + } + + // Try to find the underlying event of system notes. + if note.System { + if match := targetBranchChangeRegexp.FindStringSubmatch(note.Body); match != nil { + comment.CommentType = issues_model.CommentTypeChangeTargetBranch.String() + comment.Meta["OldRef"] = match[1] + comment.Meta["NewRef"] = match[2] + } else if strings.HasPrefix(note.Body, "enabled an automatic merge") { + comment.CommentType = issues_model.CommentTypePRScheduledToAutoMerge.String() + } else if note.Body == "canceled the automatic merge" { + comment.CommentType = issues_model.CommentTypePRUnScheduledToAutoMerge.String() + } + } + + return comment +} + // GetPullRequests returns pull requests according page and perPage func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) { if perPage > g.maxPerPage { perPage = g.maxPerPage } + view := "simple" opt := &gitlab.ListProjectMergeRequestsOptions{ ListOptions: gitlab.ListOptions{ PerPage: perPage, Page: page, }, + View: &view, } allPRs := make([]*base.PullRequest, 0, perPage) @@ -541,7 +625,13 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque if err != nil { return nil, false, fmt.Errorf("error while listing merge requests: %w", err) } - for _, pr := range prs { + for _, simplePR := range prs { + // Load merge request again by itself, as not all fields are populated in the ListProjectMergeRequests endpoint. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/29620 + pr, _, err := g.client.MergeRequests.GetMergeRequest(g.repoID, simplePR.IID, nil) + if err != nil { + return nil, false, fmt.Errorf("error while loading merge request: %w", err) + } labels := make([]*base.Label, 0, len(pr.Labels)) for _, l := range pr.Labels { @@ -566,6 +656,11 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque closeTime = pr.UpdatedAt } + mergeCommitSHA := pr.MergeCommitSHA + if mergeCommitSHA == "" { + mergeCommitSHA = pr.SquashCommitSHA + } + var locked bool if pr.State == "locked" { locked = true @@ -593,8 +688,8 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque awardPage++ } - // Add the PR ID to the Issue Count because PR and Issues share ID space in Gitea - newPRNumber := g.issueCount + int64(pr.IID) + // Generate new PR Numbers by the known Issue Numbers, because they share the same number space in Gitea, but they are independent in Gitlab + newPRNumber := g.iidResolver.generatePullRequestNumber(pr.IID) allPRs = append(allPRs, &base.PullRequest{ Title: pr.Title, @@ -608,7 +703,7 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque Closed: closeTime, Labels: labels, Merged: merged, - MergeCommitSHA: pr.MergeCommitSHA, + MergeCommitSHA: mergeCommitSHA, MergedTime: mergeTime, IsLocked: locked, Reactions: g.awardsToReactions(reactions), diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go index 731486eff21..0b9eeaed540 100644 --- a/services/migrations/gitlab_test.go +++ b/services/migrations/gitlab_test.go @@ -516,3 +516,102 @@ func TestAwardsToReactions(t *testing.T) { }, }, reactions) } + +func TestNoteToComment(t *testing.T) { + downloader := &GitlabDownloader{} + + now := time.Now() + makeTestNote := func(id int, body string, system bool) gitlab.Note { + return gitlab.Note{ + ID: id, + Author: struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + State string `json:"state"` + AvatarURL string `json:"avatar_url"` + WebURL string `json:"web_url"` + }{ + ID: 72, + Email: "test@example.com", + Username: "test", + }, + Body: body, + CreatedAt: &now, + System: system, + } + } + notes := []gitlab.Note{ + makeTestNote(1, "This is a regular comment", false), + makeTestNote(2, "enabled an automatic merge for abcd1234", true), + makeTestNote(3, "changed target branch from `master` to `main`", true), + makeTestNote(4, "canceled the automatic merge", true), + } + comments := []base.Comment{{ + IssueIndex: 17, + Index: 1, + PosterID: 72, + PosterName: "test", + PosterEmail: "test@example.com", + CommentType: "", + Content: "This is a regular comment", + Created: now, + Meta: map[string]any{}, + }, { + IssueIndex: 17, + Index: 2, + PosterID: 72, + PosterName: "test", + PosterEmail: "test@example.com", + CommentType: "pull_scheduled_merge", + Content: "enabled an automatic merge for abcd1234", + Created: now, + Meta: map[string]any{}, + }, { + IssueIndex: 17, + Index: 3, + PosterID: 72, + PosterName: "test", + PosterEmail: "test@example.com", + CommentType: "change_target_branch", + Content: "changed target branch from `master` to `main`", + Created: now, + Meta: map[string]any{ + "OldRef": "master", + "NewRef": "main", + }, + }, { + IssueIndex: 17, + Index: 4, + PosterID: 72, + PosterName: "test", + PosterEmail: "test@example.com", + CommentType: "pull_cancel_scheduled_merge", + Content: "canceled the automatic merge", + Created: now, + Meta: map[string]any{}, + }} + + for i, note := range notes { + actualComment := *downloader.convertNoteToComment(17, ¬e) + assert.EqualValues(t, actualComment, comments[i]) + } +} + +func TestGitlabIIDResolver(t *testing.T) { + r := gitlabIIDResolver{} + r.recordIssueIID(1) + r.recordIssueIID(2) + r.recordIssueIID(3) + r.recordIssueIID(2) + assert.EqualValues(t, 4, r.generatePullRequestNumber(1)) + assert.EqualValues(t, 13, r.generatePullRequestNumber(10)) + + assert.Panics(t, func() { + r := gitlabIIDResolver{} + r.recordIssueIID(1) + assert.EqualValues(t, 2, r.generatePullRequestNumber(1)) + r.recordIssueIID(3) // the generation procedure has been started, it shouldn't accept any new issue IID, so it panics + }) +} diff --git a/services/migrations/main_test.go b/services/migrations/main_test.go index 42c433fb003..d0ec6a3f8d7 100644 --- a/services/migrations/main_test.go +++ b/services/migrations/main_test.go @@ -5,7 +5,6 @@ package migrations import ( - "path/filepath" "testing" "time" @@ -16,9 +15,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func timePtr(t time.Time) *time.Time { diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go index 0ebb3411fdb..5bb30561612 100644 --- a/services/migrations/migrate.go +++ b/services/migrations/migrate.go @@ -127,7 +127,7 @@ func MigrateRepository(ctx context.Context, doer *user_model.User, ownerName str uploader := NewGiteaLocalUploader(ctx, doer, ownerName, opts.RepoName) uploader.gitServiceType = opts.GitServiceType - if err := migrateRepository(doer, downloader, uploader, opts, messenger); err != nil { + if err := migrateRepository(ctx, doer, downloader, uploader, opts, messenger); err != nil { if err1 := uploader.Rollback(); err1 != nil { log.Error("rollback failed: %v", err1) } @@ -176,7 +176,7 @@ func newDownloader(ctx context.Context, ownerName string, opts base.MigrateOptio // migrateRepository will download information and then upload it to Uploader, this is a simple // process for small repository. For a big repository, save all the data to disk // before upload is better -func migrateRepository(doer *user_model.User, downloader base.Downloader, uploader base.Uploader, opts base.MigrateOptions, messenger base.Messenger) error { +func migrateRepository(ctx context.Context, doer *user_model.User, downloader base.Downloader, uploader base.Uploader, opts base.MigrateOptions, messenger base.Messenger) error { if messenger == nil { messenger = base.NilMessenger } @@ -250,14 +250,13 @@ func migrateRepository(doer *user_model.User, downloader base.Downloader, upload } log.Warn("migrating milestones is not supported, ignored") } - msBatchSize := uploader.MaxBatchInsertSize("milestone") for len(milestones) > 0 { if len(milestones) < msBatchSize { msBatchSize = len(milestones) } - if err := uploader.CreateMilestones(milestones...); err != nil { + if err := uploader.CreateMilestones(milestones[:msBatchSize]...); err != nil { return err } milestones = milestones[msBatchSize:] diff --git a/services/migrations/update.go b/services/migrations/update.go index 2adca01dfe3..4a49206f82e 100644 --- a/services/migrations/update.go +++ b/services/migrations/update.go @@ -36,8 +36,7 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ } const batchSize = 100 - var start int - for { + for page := 0; ; page++ { select { case <-ctx.Done(): log.Warn("UpdateMigrationPosterIDByGitService(%s) cancelled", tp.Name()) @@ -45,10 +44,13 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ default: } - users, err := user_model.FindExternalUsersByProvider(user_model.FindExternalUserOptions{ + users, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{ + ListOptions: db.ListOptions{ + PageSize: batchSize, + Page: page, + }, Provider: provider, - Start: start, - Limit: batchSize, + OrderBy: "login_source_id ASC, external_id ASC", }) if err != nil { return err @@ -62,7 +64,7 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ default: } externalUserID := user.ExternalID - if err := externalaccount.UpdateMigrationsByType(tp, externalUserID, user.UserID); err != nil { + if err := externalaccount.UpdateMigrationsByType(ctx, tp, externalUserID, user.UserID); err != nil { log.Error("UpdateMigrationsByType type %s external user id %v to local user id %v failed: %v", tp.Name(), user.ExternalID, user.UserID, err) } } @@ -70,7 +72,6 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ if len(users) < batchSize { break } - start += len(users) } return nil } diff --git a/services/mirror/mirror.go b/services/mirror/mirror.go index 0fc871b214c..72e545581a1 100644 --- a/services/mirror/mirror.go +++ b/services/mirror/mirror.go @@ -46,7 +46,7 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error { var referenceID int64 if m, ok := bean.(*repo_model.Mirror); ok { - if m.GetRepository() == nil { + if m.GetRepository(ctx) == nil { log.Error("Disconnected mirror found: %d", m.ID) return nil } @@ -54,7 +54,7 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error { mirrorType = PullMirrorType referenceID = m.RepoID } else if m, ok := bean.(*repo_model.PushMirror); ok { - if m.GetRepository() == nil { + if m.GetRepository(ctx) == nil { log.Error("Disconnected push-mirror found: %d", m.ID) return nil } @@ -90,7 +90,7 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error { pullMirrorsRequested := 0 if pullLimit != 0 { - if err := repo_model.MirrorsIterate(pullLimit, func(idx int, bean any) error { + if err := repo_model.MirrorsIterate(ctx, pullLimit, func(idx int, bean any) error { if err := handler(idx, bean); err != nil { return err } diff --git a/services/mirror/mirror_pull.go b/services/mirror/mirror_pull.go index 321bd38fc32..2a38d4ba55d 100644 --- a/services/mirror/mirror_pull.go +++ b/services/mirror/mirror_pull.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -31,7 +31,7 @@ const gitShortEmptySha = "0000000" // UpdateAddress writes new address to Git repository and database func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error { remoteName := m.GetRemoteName() - repoPath := m.GetRepository().RepoPath() + repoPath := m.GetRepository(ctx).RepoPath() // Remove old remote _, _, err := git.NewCommand(ctx, "remote", "rm").AddDynamicArguments(remoteName).RunStdString(&git.RunOpts{Dir: repoPath}) if err != nil && !strings.HasPrefix(err.Error(), "exit status 128 - fatal: No such remote ") { @@ -301,7 +301,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo log.Error("SyncMirrors [repo: %-v]: %v", m.Repo, err) } - gitRepo, err := git.OpenRepository(ctx, repoPath) + gitRepo, err := gitrepo.OpenRepository(ctx, m.Repo) if err != nil { log.Error("SyncMirrors [repo: %-v]: failed to OpenRepository: %v", m.Repo, err) return nil, false @@ -313,7 +313,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo } log.Trace("SyncMirrors [repo: %-v]: syncing releases with tags...", m.Repo) - if err = repo_module.SyncReleasesWithTags(m.Repo, gitRepo); err != nil { + if err = repo_module.SyncReleasesWithTags(ctx, m.Repo, gitRepo); err != nil { log.Error("SyncMirrors [repo: %-v]: failed to synchronize tags to releases: %v", m.Repo, err) } @@ -397,7 +397,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo } log.Trace("SyncMirrors [repo: %-v]: invalidating mirror branch caches...", m.Repo) - branches, _, err := git.GetBranchesByPath(ctx, m.Repo.RepoPath(), 0, 0) + branches, _, err := gitrepo.GetBranchesByPath(ctx, m.Repo, 0, 0) if err != nil { log.Error("SyncMirrors [repo: %-v]: failed to GetBranches: %v", m.Repo, err) return nil, false @@ -428,7 +428,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { log.Error("SyncMirrors [repo_id: %v]: unable to GetMirrorByRepoID: %v", repoID, err) return false } - _ = m.GetRepository() // force load repository of mirror + _ = m.GetRepository(ctx) // force load repository of mirror ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Syncing Mirror %s/%s", m.Repo.OwnerName, m.Repo.Name)) defer finished() @@ -454,14 +454,14 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { log.Trace("SyncMirrors [repo: %-v]: no branches updated", m.Repo) } else { log.Trace("SyncMirrors [repo: %-v]: %d branches updated", m.Repo, len(results)) - gitRepo, err = git.OpenRepository(ctx, m.Repo.RepoPath()) + gitRepo, err = gitrepo.OpenRepository(ctx, m.Repo) if err != nil { log.Error("SyncMirrors [repo: %-v]: unable to OpenRepository: %v", m.Repo, err) return false } defer gitRepo.Close() - if ok := checkAndUpdateEmptyRepository(m, gitRepo, results); !ok { + if ok := checkAndUpdateEmptyRepository(ctx, m, gitRepo, results); !ok { return false } } @@ -479,9 +479,10 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { log.Error("SyncMirrors [repo: %-v]: unable to GetRefCommitID [ref_name: %s]: %v", m.Repo, result.refName, err) continue } + objectFormat := git.ObjectFormatFromName(m.Repo.ObjectFormatName) notify_service.SyncPushCommits(ctx, m.Repo.MustOwner(ctx), m.Repo, &repo_module.PushUpdateOptions{ RefFullName: result.refName, - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: commitID, }, repo_module.NewPushCommits()) notify_service.SyncCreateRef(ctx, m.Repo.MustOwner(ctx), m.Repo, result.refName, commitID) @@ -540,7 +541,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { return false } - if err = repo_model.UpdateRepositoryUpdatedTime(m.RepoID, commitDate); err != nil { + if err = repo_model.UpdateRepositoryUpdatedTime(ctx, m.RepoID, commitDate); err != nil { log.Error("SyncMirrors [repo: %-v]: unable to update repository 'updated_unix': %v", m.Repo, err) return false } @@ -550,7 +551,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { return true } -func checkAndUpdateEmptyRepository(m *repo_model.Mirror, gitRepo *git.Repository, results []*mirrorSyncResult) bool { +func checkAndUpdateEmptyRepository(ctx context.Context, m *repo_model.Mirror, gitRepo *git.Repository, results []*mirrorSyncResult) bool { if !m.Repo.IsEmpty { return true } @@ -589,10 +590,10 @@ func checkAndUpdateEmptyRepository(m *repo_model.Mirror, gitRepo *git.Repository m.Repo.DefaultBranch = firstName } // Update the git repository default branch - if err := gitRepo.SetDefaultBranch(m.Repo.DefaultBranch); err != nil { + if err := gitrepo.SetDefaultBranch(ctx, m.Repo, m.Repo.DefaultBranch); err != nil { if !git.IsErrUnsupportedVersion(err) { log.Error("Failed to update default branch of underlying git repository %-v. Error: %v", m.Repo, err) - desc := fmt.Sprintf("Failed to uupdate default branch of underlying git repository '%s': %v", m.Repo.RepoPath(), err) + desc := fmt.Sprintf("Failed to update default branch of underlying git repository '%s': %v", m.Repo.RepoPath(), err) if err = system_model.CreateRepositoryNotice(desc); err != nil { log.Error("CreateRepositoryNotice: %v", err) } @@ -601,9 +602,9 @@ func checkAndUpdateEmptyRepository(m *repo_model.Mirror, gitRepo *git.Repository } m.Repo.IsEmpty = false // Update the is empty and default_branch columns - if err := repo_model.UpdateRepositoryCols(db.DefaultContext, m.Repo, "default_branch", "is_empty"); err != nil { + if err := repo_model.UpdateRepositoryCols(ctx, m.Repo, "default_branch", "is_empty"); err != nil { log.Error("Failed to update default branch of repository %-v. Error: %v", m.Repo, err) - desc := fmt.Sprintf("Failed to uupdate default branch of repository '%s': %v", m.Repo.RepoPath(), err) + desc := fmt.Sprintf("Failed to update default branch of repository '%s': %v", m.Repo.RepoPath(), err) if err = system_model.CreateRepositoryNotice(desc); err != nil { log.Error("CreateRepositoryNotice: %v", err) } diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index 594d31df89b..21ba0afeffd 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -12,8 +12,10 @@ import ( "strings" "time" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -65,7 +67,7 @@ func AddPushMirrorRemote(ctx context.Context, m *repo_model.PushMirror, addr str // RemovePushMirrorRemote removes the push mirror remote. func RemovePushMirrorRemote(ctx context.Context, m *repo_model.PushMirror) error { cmd := git.NewCommand(ctx, "remote", "rm").AddDynamicArguments(m.RemoteName) - _ = m.GetRepository() + _ = m.GetRepository(ctx) if _, _, err := cmd.RunStdString(&git.RunOpts{Dir: m.Repo.RepoPath()}); err != nil { return err @@ -93,13 +95,14 @@ func SyncPushMirror(ctx context.Context, mirrorID int64) bool { log.Error("PANIC whilst syncPushMirror[%d] Panic: %v\nStacktrace: %s", mirrorID, err, log.Stack(2)) }() - m, err := repo_model.GetPushMirror(ctx, repo_model.PushMirrorOptions{ID: mirrorID}) - if err != nil { + // TODO: Handle "!exist" better + m, exist, err := db.GetByID[repo_model.PushMirror](ctx, mirrorID) + if err != nil || !exist { log.Error("GetPushMirrorByID [%d]: %v", mirrorID, err) return false } - _ = m.GetRepository() + _ = m.GetRepository(ctx) m.LastError = "" @@ -129,7 +132,11 @@ func SyncPushMirror(ctx context.Context, mirrorID int64) bool { func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second - performPush := func(path string) error { + performPush := func(repo *repo_model.Repository, isWiki bool) error { + path := repo.RepoPath() + if isWiki { + path = repo.WikiPath() + } remoteURL, err := git.GetRemoteURL(ctx, path, m.RemoteName) if err != nil { log.Error("GetRemoteAddress(%s) Error %v", path, err) @@ -139,7 +146,12 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { if setting.LFS.StartServer { log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo) - gitRepo, err := git.OpenRepository(ctx, path) + var gitRepo *git.Repository + if isWiki { + gitRepo, err = gitrepo.OpenWikiRepository(ctx, repo) + } else { + gitRepo, err = gitrepo.OpenRepository(ctx, repo) + } if err != nil { log.Error("OpenRepository: %v", err) return errors.New("Unexpected error") @@ -169,16 +181,15 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { return nil } - err := performPush(m.Repo.RepoPath()) + err := performPush(m.Repo, false) if err != nil { return err } if m.Repo.HasWiki() { - wikiPath := m.Repo.WikiPath() - _, err := git.GetRemoteAddress(ctx, wikiPath, m.RemoteName) + _, err := git.GetRemoteAddress(ctx, m.Repo.WikiPath(), m.RemoteName) if err == nil { - err := performPush(wikiPath) + err := performPush(m.Repo, true) if err != nil { return err } diff --git a/services/notify/notify.go b/services/notify/notify.go index 16fbb6325d0..0c8262ef7a5 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -91,7 +91,7 @@ func AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues // NewPullRequest notifies new pull request to notifiers func NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { if err := pr.LoadIssue(ctx); err != nil { - log.Error("%v", err) + log.Error("LoadIssue failed: %v", err) return } if err := pr.Issue.LoadPoster(ctx); err != nil { @@ -112,7 +112,7 @@ func PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *iss // PullRequestReview notifies new pull request review func PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { if err := review.LoadReviewer(ctx); err != nil { - log.Error("%v", err) + log.Error("LoadReviewer failed: %v", err) return } for _, notifier := range notifiers { diff --git a/services/org/org.go b/services/org/org.go index a62e5b6fc8f..dca7794b471 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -15,17 +15,24 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" - user_service "code.gitea.io/gitea/services/user" + repo_service "code.gitea.io/gitea/services/repository" ) // DeleteOrganization completely and permanently deletes everything of organization. -func DeleteOrganization(org *org_model.Organization) error { - ctx, commiter, err := db.TxContext(db.DefaultContext) +func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge bool) error { + ctx, commiter, err := db.TxContext(ctx) if err != nil { return err } defer commiter.Close() + if purge { + err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, org.AsUser()) + if err != nil { + return err + } + } + // Check ownership of repository. count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: org.ID}) if err != nil { @@ -67,8 +74,3 @@ func DeleteOrganization(org *org_model.Organization) error { return nil } - -// RenameOrganization renames an organization. -func RenameOrganization(ctx context.Context, org *org_model.Organization, newName string) error { - return user_service.RenameUser(ctx, org.AsUser(), newName) -} diff --git a/services/org/org_test.go b/services/org/org_test.go index cc22595c6f1..e7d2a18ea96 100644 --- a/services/org/org_test.go +++ b/services/org/org_test.go @@ -4,10 +4,10 @@ package org import ( - "path/filepath" "testing" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -16,25 +16,23 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestDeleteOrganization(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6}) - assert.NoError(t, DeleteOrganization(org)) + assert.NoError(t, DeleteOrganization(db.DefaultContext, org, false)) unittest.AssertNotExistsBean(t, &organization.Organization{ID: 6}) unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: 6}) unittest.AssertNotExistsBean(t, &organization.Team{OrgID: 6}) org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) - err := DeleteOrganization(org) + err := DeleteOrganization(db.DefaultContext, org, false) assert.Error(t, err) assert.True(t, models.IsErrUserOwnRepos(err)) user := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 5}) - assert.Error(t, DeleteOrganization(user)) + assert.Error(t, DeleteOrganization(db.DefaultContext, user, false)) unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) } diff --git a/services/packages/alpine/repository.go b/services/packages/alpine/repository.go index 24e673289a9..664ab345598 100644 --- a/services/packages/alpine/repository.go +++ b/services/packages/alpine/repository.go @@ -23,6 +23,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" alpine_model "code.gitea.io/gitea/models/packages/alpine" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/json" packages_module "code.gitea.io/gitea/modules/packages" alpine_module "code.gitea.io/gitea/modules/packages/alpine" @@ -30,12 +31,15 @@ import ( packages_service "code.gitea.io/gitea/services/packages" ) -const IndexFilename = "APKINDEX.tar.gz" +const ( + IndexFilename = "APKINDEX" + IndexArchiveFilename = IndexFilename + ".tar.gz" +) // GetOrCreateRepositoryVersion gets or creates the internal repository package // The Alpine registry needs multiple index files which are stored in this package. -func GetOrCreateRepositoryVersion(ownerID int64) (*packages_model.PackageVersion, error) { - return packages_service.GetOrCreateInternalPackageVersion(ownerID, packages_model.TypeAlpine, alpine_module.RepositoryPackage, alpine_module.RepositoryVersion) +func GetOrCreateRepositoryVersion(ctx context.Context, ownerID int64) (*packages_model.PackageVersion, error) { + return packages_service.GetOrCreateInternalPackageVersion(ctx, ownerID, packages_model.TypeAlpine, alpine_module.RepositoryPackage, alpine_module.RepositoryVersion) } // GetOrCreateKeyPair gets or creates the RSA keys used to sign repository files @@ -70,7 +74,7 @@ func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, err // BuildAllRepositoryFiles (re)builds all repository files for every available distributions, components and architectures func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { - pv, err := GetOrCreateRepositoryVersion(ownerID) + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) if err != nil { return err } @@ -82,10 +86,7 @@ func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { } for _, pf := range pfs { - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -118,12 +119,27 @@ func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { // BuildSpecificRepositoryFiles builds index files for the repository func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, branch, repository, architecture string) error { - pv, err := GetOrCreateRepositoryVersion(ownerID) + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) if err != nil { return err } - return buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture) + architectures := container.SetOf(architecture) + if architecture == alpine_module.NoArch { + // Update all other architectures too when updating the noarch index + additionalArchitectures, err := alpine_model.GetArchitectures(ctx, ownerID, repository) + if err != nil { + return err + } + architectures.AddMultiple(additionalArchitectures...) + } + + for architecture := range architectures { + if err := buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture); err != nil { + return err + } + } + return nil } type packageData struct { @@ -136,8 +152,7 @@ type packageData struct { type packageCache = map[*packages_model.PackageFile]*packageData -// https://wiki.alpinelinux.org/wiki/Apk_spec#APKINDEX_Format -func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, branch, repository, architecture string) error { +func searchPackageFiles(ctx context.Context, ownerID int64, branch, repository, architecture string) ([]*packages_model.PackageFile, error) { pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ OwnerID: ownerID, PackageType: packages_model.TypeAlpine, @@ -148,21 +163,37 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package alpine_module.PropertyArchitecture: architecture, }, }) + if err != nil { + return nil, err + } + return pfs, nil +} + +// https://wiki.alpinelinux.org/wiki/Apk_spec#APKINDEX_Format +func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, branch, repository, architecture string) error { + pfs, err := searchPackageFiles(ctx, ownerID, branch, repository, architecture) if err != nil { return err } + if architecture != alpine_module.NoArch { + // Add all noarch packages too + noarchFiles, err := searchPackageFiles(ctx, ownerID, branch, repository, alpine_module.NoArch) + if err != nil { + return err + } + pfs = append(pfs, noarchFiles...) + } // Delete the package indices if there are no packages if len(pfs) == 0 { - pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, IndexFilename, fmt.Sprintf("%s|%s|%s", branch, repository, architecture)) + pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, IndexArchiveFilename, fmt.Sprintf("%s|%s|%s", branch, repository, architecture)) if err != nil && !errors.Is(err, util.ErrNotExist) { return err + } else if pf == nil { + return nil } - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - return packages_model.DeleteFileByID(ctx, pf.ID) + return packages_service.DeletePackageFile(ctx, pf) } // Cache data needed for all repository files @@ -210,7 +241,7 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package fmt.Fprintf(&buf, "C:%s\n", pd.FileMetadata.Checksum) fmt.Fprintf(&buf, "P:%s\n", pd.Package.Name) fmt.Fprintf(&buf, "V:%s\n", pd.Version.Version) - fmt.Fprintf(&buf, "A:%s\n", pd.FileMetadata.Architecture) + fmt.Fprintf(&buf, "A:%s\n", architecture) if pd.VersionMetadata.Description != "" { fmt.Fprintf(&buf, "T:%s\n", pd.VersionMetadata.Description) } @@ -234,13 +265,21 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package if len(pd.FileMetadata.Provides) > 0 { fmt.Fprintf(&buf, "p:%s\n", strings.Join(pd.FileMetadata.Provides, " ")) } + if pd.FileMetadata.InstallIf != "" { + fmt.Fprintf(&buf, "i:%s\n", pd.FileMetadata.InstallIf) + } + if pd.FileMetadata.ProviderPriority > 0 { + fmt.Fprintf(&buf, "k:%d\n", pd.FileMetadata.ProviderPriority) + } fmt.Fprint(&buf, "\n") } unsignedIndexContent, _ := packages_module.NewHashedBuffer() + defer unsignedIndexContent.Close() + h := sha1.New() - if err := writeGzipStream(io.MultiWriter(unsignedIndexContent, h), "APKINDEX", buf.Bytes(), true); err != nil { + if err := writeGzipStream(io.MultiWriter(unsignedIndexContent, h), IndexFilename, buf.Bytes(), true); err != nil { return err } @@ -275,6 +314,7 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package } signedIndexContent, _ := packages_module.NewHashedBuffer() + defer signedIndexContent.Close() if err := writeGzipStream( signedIndexContent, @@ -290,16 +330,22 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package } _, err = packages_service.AddFileToPackageVersionInternal( + ctx, repoVersion, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ - Filename: IndexFilename, + Filename: IndexArchiveFilename, CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), }, Creator: user_model.NewGhostUser(), Data: signedIndexContent, IsLead: false, OverwriteExisting: true, + Properties: map[string]string{ + alpine_module.PropertyBranch: branch, + alpine_module.PropertyRepository: repository, + alpine_module.PropertyArchitecture: architecture, + }, }, ) return err diff --git a/services/packages/auth.go b/services/packages/auth.go index 2f78b26f506..8263c28bed0 100644 --- a/services/packages/auth.go +++ b/services/packages/auth.go @@ -33,7 +33,7 @@ func CreateAuthorizationToken(u *user_model.User) (string, error) { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(setting.SecretKey)) + tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret()) if err != nil { return "", err } @@ -57,7 +57,7 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } - return []byte(setting.SecretKey), nil + return setting.GetGeneralTokenSigningSecret(), nil }) if err != nil { return 0, err diff --git a/services/packages/cargo/index.go b/services/packages/cargo/index.go index 0561f168e10..e8a8313625d 100644 --- a/services/packages/cargo/index.go +++ b/services/packages/cargo/index.go @@ -106,10 +106,16 @@ func RebuildIndex(ctx context.Context, doer, owner *user_model.User) error { ) } -func AddOrUpdatePackageIndex(ctx context.Context, doer, owner *user_model.User, packageID int64) error { - repo, err := getOrCreateIndexRepository(ctx, doer, owner) +func UpdatePackageIndexIfExists(ctx context.Context, doer, owner *user_model.User, packageID int64) error { + // We do not want to force the creation of the repo here + // cargo http index does not rely on the repo itself, + // so if the repo does not exist, we just do nothing. + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName) if err != nil { - return err + if errors.Is(err, util.ErrNotExist) { + return nil + } + return fmt.Errorf("GetRepositoryByOwnerAndName: %w", err) } p, err := packages_model.GetPackageByID(ctx, packageID) @@ -261,11 +267,11 @@ func alterRepositoryContent(ctx context.Context, doer *user_model.User, repo *re defer t.Close() var lastCommitID string - if err := t.Clone(repo.DefaultBranch); err != nil { + if err := t.Clone(repo.DefaultBranch, true); err != nil { if !git.IsErrBranchNotExist(err) || !repo.IsEmpty { return err } - if err := t.Init(); err != nil { + if err := t.Init(repo.ObjectFormatName); err != nil { return err } } else { diff --git a/services/packages/cleanup/cleanup.go b/services/packages/cleanup/cleanup.go index 77bcfb19423..5d5120c6a0a 100644 --- a/services/packages/cleanup/cleanup.go +++ b/services/packages/cleanup/cleanup.go @@ -12,12 +12,14 @@ import ( packages_model "code.gitea.io/gitea/models/packages" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" - "code.gitea.io/gitea/modules/util" packages_service "code.gitea.io/gitea/services/packages" + alpine_service "code.gitea.io/gitea/services/packages/alpine" cargo_service "code.gitea.io/gitea/services/packages/cargo" container_service "code.gitea.io/gitea/services/packages/container" debian_service "code.gitea.io/gitea/services/packages/debian" + rpm_service "code.gitea.io/gitea/services/packages/rpm" ) // Task method to execute cleanup rules and cleanup expired package data @@ -58,7 +60,7 @@ func ExecuteCleanupRules(outerCtx context.Context) error { for _, p := range packages { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ PackageID: p.ID, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), Sort: packages_model.SortCreatedDesc, Paginator: db.NewAbsoluteListOptions(pcr.KeepCount, 200), }) @@ -110,8 +112,8 @@ func ExecuteCleanupRules(outerCtx context.Context) error { if err != nil { return fmt.Errorf("GetUserByID failed: %w", err) } - if err := cargo_service.AddOrUpdatePackageIndex(ctx, owner, owner, p.ID); err != nil { - return fmt.Errorf("CleanupRule [%d]: cargo.AddOrUpdatePackageIndex failed: %w", pcr.ID, err) + if err := cargo_service.UpdatePackageIndexIfExists(ctx, owner, owner, p.ID); err != nil { + return fmt.Errorf("CleanupRule [%d]: cargo.UpdatePackageIndexIfExists failed: %w", pcr.ID, err) } } } @@ -122,6 +124,14 @@ func ExecuteCleanupRules(outerCtx context.Context) error { if err := debian_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil { return fmt.Errorf("CleanupRule [%d]: debian.BuildAllRepositoryFiles failed: %w", pcr.ID, err) } + } else if pcr.Type == packages_model.TypeAlpine { + if err := alpine_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil { + return fmt.Errorf("CleanupRule [%d]: alpine.BuildAllRepositoryFiles failed: %w", pcr.ID, err) + } + } else if pcr.Type == packages_model.TypeRpm { + if err := rpm_service.BuildAllRepositoryFiles(ctx, pcr.OwnerID); err != nil { + return fmt.Errorf("CleanupRule [%d]: rpm.BuildAllRepositoryFiles failed: %w", pcr.ID, err) + } } } return nil diff --git a/services/packages/container/cleanup.go b/services/packages/container/cleanup.go index 1a9ef263914..3f5f43bbc0b 100644 --- a/services/packages/container/cleanup.go +++ b/services/packages/container/cleanup.go @@ -9,8 +9,9 @@ import ( packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" + "code.gitea.io/gitea/modules/optional" container_module "code.gitea.io/gitea/modules/packages/container" - "code.gitea.io/gitea/modules/util" + packages_service "code.gitea.io/gitea/services/packages" digest "github.com/opencontainers/go-digest" ) @@ -47,10 +48,7 @@ func cleanupExpiredUploadedBlobs(ctx context.Context, olderThan time.Duration) e } for _, pf := range pfs { - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -61,8 +59,8 @@ func cleanupExpiredUploadedBlobs(ctx context.Context, olderThan time.Duration) e ExactMatch: true, Value: container_model.UploadVersion, }, - IsInternal: util.OptionalBoolTrue, - HasFiles: util.OptionalBoolFalse, + IsInternal: optional.Some(true), + HasFiles: optional.Some(false), }) if err != nil { return err diff --git a/services/packages/debian/repository.go b/services/packages/debian/repository.go index 46b1a94b815..611faa6adea 100644 --- a/services/packages/debian/repository.go +++ b/services/packages/debian/repository.go @@ -32,8 +32,8 @@ import ( // GetOrCreateRepositoryVersion gets or creates the internal repository package // The Debian registry needs multiple index files which are stored in this package. -func GetOrCreateRepositoryVersion(ownerID int64) (*packages_model.PackageVersion, error) { - return packages_service.GetOrCreateInternalPackageVersion(ownerID, packages_model.TypeDebian, debian_module.RepositoryPackage, debian_module.RepositoryVersion) +func GetOrCreateRepositoryVersion(ctx context.Context, ownerID int64) (*packages_model.PackageVersion, error) { + return packages_service.GetOrCreateInternalPackageVersion(ctx, ownerID, packages_model.TypeDebian, debian_module.RepositoryPackage, debian_module.RepositoryVersion) } // GetOrCreateKeyPair gets or creates the PGP keys used to sign repository files @@ -67,7 +67,7 @@ func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, err } func generateKeypair() (string, string, error) { - e, err := openpgp.NewEntity(setting.AppName, "Debian Registry", "", nil) + e, err := openpgp.NewEntity("", "Debian Registry", "", nil) if err != nil { return "", "", err } @@ -98,7 +98,7 @@ func generateKeypair() (string, string, error) { // BuildAllRepositoryFiles (re)builds all repository files for every available distributions, components and architectures func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { - pv, err := GetOrCreateRepositoryVersion(ownerID) + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) if err != nil { return err } @@ -110,10 +110,7 @@ func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { } for _, pf := range pfs { - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -147,7 +144,7 @@ func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { // BuildSpecificRepositoryFiles builds index files for the repository func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, distribution, component, architecture string) error { - pv, err := GetOrCreateRepositoryVersion(ownerID) + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) if err != nil { return err } @@ -165,29 +162,27 @@ func buildRepositoryFiles(ctx context.Context, ownerID int64, repoVersion *packa // https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices func buildPackagesIndices(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, distribution, component, architecture string) error { - pfds, err := debian_model.SearchLatestPackages(ctx, &debian_model.PackageSearchOptions{ + opts := &debian_model.PackageSearchOptions{ OwnerID: ownerID, Distribution: distribution, Component: component, Architecture: architecture, - }) - if err != nil { - return err } // Delete the package indices if there are no packages - if len(pfds) == 0 { + if has, err := debian_model.ExistPackages(ctx, opts); err != nil { + return err + } else if !has { key := fmt.Sprintf("%s|%s|%s", distribution, component, architecture) for _, filename := range []string{"Packages", "Packages.gz", "Packages.xz"} { pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, filename, key) if err != nil && !errors.Is(err, util.ErrNotExist) { return err + } else if pf == nil { + continue } - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -196,17 +191,22 @@ func buildPackagesIndices(ctx context.Context, ownerID int64, repoVersion *packa } packagesContent, _ := packages_module.NewHashedBuffer() + defer packagesContent.Close() packagesGzipContent, _ := packages_module.NewHashedBuffer() + defer packagesGzipContent.Close() + gzw := gzip.NewWriter(packagesGzipContent) packagesXzContent, _ := packages_module.NewHashedBuffer() + defer packagesXzContent.Close() + xzw, _ := xz.NewWriter(packagesXzContent) w := io.MultiWriter(packagesContent, gzw, xzw) addSeparator := false - for _, pfd := range pfds { + if err := debian_model.SearchPackages(ctx, opts, func(pfd *packages_model.PackageFileDescriptor) { if addSeparator { fmt.Fprintln(w) } @@ -220,6 +220,8 @@ func buildPackagesIndices(ctx context.Context, ownerID int64, repoVersion *packa fmt.Fprintf(w, "SHA1: %s\n", pfd.Blob.HashSHA1) fmt.Fprintf(w, "SHA256: %s\n", pfd.Blob.HashSHA256) fmt.Fprintf(w, "SHA512: %s\n", pfd.Blob.HashSHA512) + }); err != nil { + return err } gzw.Close() @@ -233,7 +235,8 @@ func buildPackagesIndices(ctx context.Context, ownerID int64, repoVersion *packa {"Packages.gz", packagesGzipContent}, {"Packages.xz", packagesXzContent}, } { - _, err = packages_service.AddFileToPackageVersionInternal( + _, err := packages_service.AddFileToPackageVersionInternal( + ctx, repoVersion, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ @@ -279,12 +282,11 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, filename, distribution) if err != nil && !errors.Is(err, util.ErrNotExist) { return err + } else if pf == nil { + continue } - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -322,6 +324,8 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages } inReleaseContent, _ := packages_module.NewHashedBuffer() + defer inReleaseContent.Close() + sw, err := clearsign.Encode(inReleaseContent, e.PrivateKey, nil) if err != nil { return err @@ -338,7 +342,7 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages fmt.Fprintf(w, "Components: %s\n", strings.Join(components, " ")) fmt.Fprintf(w, "Architectures: %s\n", strings.Join(architectures, " ")) fmt.Fprintf(w, "Date: %s\n", time.Now().UTC().Format(time.RFC1123)) - fmt.Fprint(w, "Acquire-By-Hash: yes") + fmt.Fprint(w, "Acquire-By-Hash: yes\n") pfds, err := packages_model.GetPackageFileDescriptors(ctx, pfs) if err != nil { @@ -366,11 +370,14 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages sw.Close() releaseGpgContent, _ := packages_module.NewHashedBuffer() + defer releaseGpgContent.Close() + if err := openpgp.ArmoredDetachSign(releaseGpgContent, e, bytes.NewReader(buf.Bytes()), nil); err != nil { return err } releaseContent, _ := packages_module.CreateHashedBufferFromReader(&buf) + defer releaseContent.Close() for _, file := range []struct { Name string @@ -381,6 +388,7 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages {"InRelease", inReleaseContent}, } { _, err = packages_service.AddFileToPackageVersionInternal( + ctx, repoVersion, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ diff --git a/services/packages/packages.go b/services/packages/packages.go index d7583464569..64b1ddd8696 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -18,10 +18,10 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" - "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" ) @@ -66,28 +66,28 @@ type PackageFileCreationInfo struct { } // CreatePackageAndAddFile creates a package with a file. If the same package exists already, ErrDuplicatePackageVersion is returned -func CreatePackageAndAddFile(pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { - return createPackageAndAddFile(pvci, pfci, false) +func CreatePackageAndAddFile(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { + return createPackageAndAddFile(ctx, pvci, pfci, false) } // CreatePackageOrAddFileToExisting creates a package with a file or adds the file if the package exists already -func CreatePackageOrAddFileToExisting(pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { - return createPackageAndAddFile(pvci, pfci, true) +func CreatePackageOrAddFileToExisting(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { + return createPackageAndAddFile(ctx, pvci, pfci, true) } -func createPackageAndAddFile(pvci *PackageCreationInfo, pfci *PackageFileCreationInfo, allowDuplicate bool) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { - ctx, committer, err := db.TxContext(db.DefaultContext) +func createPackageAndAddFile(ctx context.Context, pvci *PackageCreationInfo, pfci *PackageFileCreationInfo, allowDuplicate bool) (*packages_model.PackageVersion, *packages_model.PackageFile, error) { + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return nil, nil, err } defer committer.Close() - pv, created, err := createPackageAndVersion(ctx, pvci, allowDuplicate) + pv, created, err := createPackageAndVersion(dbCtx, pvci, allowDuplicate) if err != nil { return nil, nil, err } - pf, pb, blobCreated, err := addFileToPackageVersion(ctx, pv, &pvci.PackageInfo, pfci) + pf, pb, blobCreated, err := addFileToPackageVersion(dbCtx, pv, &pvci.PackageInfo, pfci) removeBlob := false defer func() { if blobCreated && removeBlob { @@ -108,12 +108,12 @@ func createPackageAndAddFile(pvci *PackageCreationInfo, pfci *PackageFileCreatio } if created { - pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv) + pd, err := packages_model.GetPackageDescriptor(ctx, pv) if err != nil { return nil, nil, err } - notify_service.PackageCreate(db.DefaultContext, pvci.Creator, pd) + notify_service.PackageCreate(ctx, pvci.Creator, pd) } return pv, pf, nil @@ -189,8 +189,8 @@ func createPackageAndVersion(ctx context.Context, pvci *PackageCreationInfo, all } // AddFileToExistingPackage adds a file to an existing package. If the package does not exist, ErrPackageNotExist is returned -func AddFileToExistingPackage(pvi *PackageInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, error) { - return addFileToPackageWrapper(func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) { +func AddFileToExistingPackage(ctx context.Context, pvi *PackageInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, error) { + return addFileToPackageWrapper(ctx, func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) { pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvi.Owner.ID, pvi.PackageType, pvi.Name, pvi.Version) if err != nil { return nil, nil, false, err @@ -202,14 +202,14 @@ func AddFileToExistingPackage(pvi *PackageInfo, pfci *PackageFileCreationInfo) ( // AddFileToPackageVersionInternal adds a file to the package // This method skips quota checks and should only be used for system-managed packages. -func AddFileToPackageVersionInternal(pv *packages_model.PackageVersion, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, error) { - return addFileToPackageWrapper(func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) { +func AddFileToPackageVersionInternal(ctx context.Context, pv *packages_model.PackageVersion, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, error) { + return addFileToPackageWrapper(ctx, func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) { return addFileToPackageVersionUnchecked(ctx, pv, pfci) }) } -func addFileToPackageWrapper(fn func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error)) (*packages_model.PackageFile, error) { - ctx, committer, err := db.TxContext(db.DefaultContext) +func addFileToPackageWrapper(ctx context.Context, fn func(ctx context.Context) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error)) (*packages_model.PackageFile, error) { + ctx, committer, err := db.TxContext(ctx) if err != nil { return nil, err } @@ -330,7 +330,7 @@ func CheckCountQuotaExceeded(ctx context.Context, doer, owner *user_model.User) if setting.Packages.LimitTotalOwnerCount > -1 { totalCount, err := packages_model.CountVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: owner.ID, - IsInternal: util.OptionalBoolFalse, + IsInternal: optional.Some(false), }) if err != nil { log.Error("CountVersions failed: %v", err) @@ -418,10 +418,10 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p // GetOrCreateInternalPackageVersion gets or creates an internal package // Some package types need such internal packages for housekeeping. -func GetOrCreateInternalPackageVersion(ownerID int64, packageType packages_model.Type, name, version string) (*packages_model.PackageVersion, error) { +func GetOrCreateInternalPackageVersion(ctx context.Context, ownerID int64, packageType packages_model.Type, name, version string) (*packages_model.PackageVersion, error) { var pv *packages_model.PackageVersion - return pv, db.WithTx(db.DefaultContext, func(ctx context.Context) error { + return pv, db.WithTx(ctx, func(ctx context.Context) error { p := &packages_model.Package{ OwnerID: ownerID, Type: packageType, @@ -457,31 +457,31 @@ func GetOrCreateInternalPackageVersion(ownerID int64, packageType packages_model } // RemovePackageVersionByNameAndVersion deletes a package version and all associated files -func RemovePackageVersionByNameAndVersion(doer *user_model.User, pvi *PackageInfo) error { - pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, pvi.Owner.ID, pvi.PackageType, pvi.Name, pvi.Version) +func RemovePackageVersionByNameAndVersion(ctx context.Context, doer *user_model.User, pvi *PackageInfo) error { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvi.Owner.ID, pvi.PackageType, pvi.Name, pvi.Version) if err != nil { return err } - return RemovePackageVersion(doer, pv) + return RemovePackageVersion(ctx, doer, pv) } // RemovePackageVersion deletes the package version and all associated files -func RemovePackageVersion(doer *user_model.User, pv *packages_model.PackageVersion) error { - ctx, committer, err := db.TxContext(db.DefaultContext) +func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packages_model.PackageVersion) error { + dbCtx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - pd, err := packages_model.GetPackageDescriptor(ctx, pv) + pd, err := packages_model.GetPackageDescriptor(dbCtx, pv) if err != nil { return err } log.Trace("Deleting package: %v", pv.ID) - if err := DeletePackageVersionAndReferences(ctx, pv); err != nil { + if err := DeletePackageVersionAndReferences(dbCtx, pv); err != nil { return err } @@ -489,16 +489,16 @@ func RemovePackageVersion(doer *user_model.User, pv *packages_model.PackageVersi return err } - notify_service.PackageDelete(db.DefaultContext, doer, pd) + notify_service.PackageDelete(ctx, doer, pd) return nil } // RemovePackageFileAndVersionIfUnreferenced deletes the package file and the version if there are no referenced files afterwards -func RemovePackageFileAndVersionIfUnreferenced(doer *user_model.User, pf *packages_model.PackageFile) error { +func RemovePackageFileAndVersionIfUnreferenced(ctx context.Context, doer *user_model.User, pf *packages_model.PackageFile) error { var pd *packages_model.PackageDescriptor - if err := db.WithTx(db.DefaultContext, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { if err := DeletePackageFile(ctx, pf); err != nil { return err } @@ -529,7 +529,7 @@ func RemovePackageFileAndVersionIfUnreferenced(doer *user_model.User, pf *packag } if pd != nil { - notify_service.PackageDelete(db.DefaultContext, doer, pd) + notify_service.PackageDelete(ctx, doer, pd) } return nil @@ -640,7 +640,7 @@ func RemoveAllPackages(ctx context.Context, userID int64) (int, error) { Page: 1, }, OwnerID: userID, - IsInternal: util.OptionalBoolNone, + IsInternal: optional.None[bool](), }) if err != nil { return count, fmt.Errorf("GetOwnedPackages[%d]: %w", userID, err) diff --git a/services/packages/rpm/repository.go b/services/packages/rpm/repository.go index d115197197b..c52c8a5dd98 100644 --- a/services/packages/rpm/repository.go +++ b/services/packages/rpm/repository.go @@ -18,11 +18,11 @@ import ( "time" packages_model "code.gitea.io/gitea/models/packages" + rpm_model "code.gitea.io/gitea/models/packages/rpm" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" packages_module "code.gitea.io/gitea/modules/packages" rpm_module "code.gitea.io/gitea/modules/packages/rpm" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" packages_service "code.gitea.io/gitea/services/packages" @@ -33,8 +33,8 @@ import ( // GetOrCreateRepositoryVersion gets or creates the internal repository package // The RPM registry needs multiple metadata files which are stored in this package. -func GetOrCreateRepositoryVersion(ownerID int64) (*packages_model.PackageVersion, error) { - return packages_service.GetOrCreateInternalPackageVersion(ownerID, packages_model.TypeRpm, rpm_module.RepositoryPackage, rpm_module.RepositoryVersion) +func GetOrCreateRepositoryVersion(ctx context.Context, ownerID int64) (*packages_model.PackageVersion, error) { + return packages_service.GetOrCreateInternalPackageVersion(ctx, ownerID, packages_model.TypeRpm, rpm_module.RepositoryPackage, rpm_module.RepositoryVersion) } // GetOrCreateKeyPair gets or creates the PGP keys used to sign repository metadata files @@ -68,7 +68,7 @@ func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, err } func generateKeypair() (string, string, error) { - e, err := openpgp.NewEntity(setting.AppName, "RPM Registry", "", nil) + e, err := openpgp.NewEntity("", "RPM Registry", "", nil) if err != nil { return "", "", err } @@ -97,6 +97,39 @@ func generateKeypair() (string, string, error) { return priv.String(), pub.String(), nil } +// BuildAllRepositoryFiles (re)builds all repository files for every available group +func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) + if err != nil { + return err + } + + // 1. Delete all existing repository files + pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) + if err != nil { + return err + } + + for _, pf := range pfs { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { + return err + } + } + + // 2. (Re)Build repository files for existing packages + groups, err := rpm_model.GetGroups(ctx, ownerID) + if err != nil { + return err + } + for _, group := range groups { + if err := BuildSpecificRepositoryFiles(ctx, ownerID, group); err != nil { + return fmt.Errorf("failed to build repository files [%s]: %w", group, err) + } + } + + return nil +} + type repoChecksum struct { Value string `xml:",chardata"` Type string `xml:"type,attr"` @@ -127,16 +160,17 @@ type packageData struct { type packageCache = map[*packages_model.PackageFile]*packageData // BuildSpecificRepositoryFiles builds metadata files for the repository -func BuildRepositoryFiles(ctx context.Context, ownerID int64) error { - pv, err := GetOrCreateRepositoryVersion(ownerID) +func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, group string) error { + pv, err := GetOrCreateRepositoryVersion(ctx, ownerID) if err != nil { return err } pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ - OwnerID: ownerID, - PackageType: packages_model.TypeRpm, - Query: "%.rpm", + OwnerID: ownerID, + PackageType: packages_model.TypeRpm, + Query: "%.rpm", + CompositeKey: group, }) if err != nil { return err @@ -149,10 +183,7 @@ func BuildRepositoryFiles(ctx context.Context, ownerID int64) error { return err } for _, pf := range pfs { - if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { - return err - } - if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { + if err := packages_service.DeletePackageFile(ctx, pf); err != nil { return err } } @@ -198,15 +229,15 @@ func BuildRepositoryFiles(ctx context.Context, ownerID int64) error { cache[pf] = pd } - primary, err := buildPrimary(pv, pfs, cache) + primary, err := buildPrimary(ctx, pv, pfs, cache, group) if err != nil { return err } - filelists, err := buildFilelists(pv, pfs, cache) + filelists, err := buildFilelists(ctx, pv, pfs, cache, group) if err != nil { return err } - other, err := buildOther(pv, pfs, cache) + other, err := buildOther(ctx, pv, pfs, cache, group) if err != nil { return err } @@ -220,11 +251,12 @@ func BuildRepositoryFiles(ctx context.Context, ownerID int64) error { filelists, other, }, + group, ) } // https://docs.pulpproject.org/en/2.19/plugins/pulp_rpm/tech-reference/rpm.html#repomd-xml -func buildRepomd(ctx context.Context, pv *packages_model.PackageVersion, ownerID int64, data []*repoData) error { +func buildRepomd(ctx context.Context, pv *packages_model.PackageVersion, ownerID int64, data []*repoData, group string) error { type Repomd struct { XMLName xml.Name `xml:"repomd"` Xmlns string `xml:"xmlns,attr"` @@ -258,11 +290,14 @@ func buildRepomd(ctx context.Context, pv *packages_model.PackageVersion, ownerID } repomdAscContent, _ := packages_module.NewHashedBuffer() + defer repomdAscContent.Close() + if err := openpgp.ArmoredDetachSign(repomdAscContent, e, bytes.NewReader(buf.Bytes()), nil); err != nil { return err } repomdContent, _ := packages_module.CreateHashedBufferFromReader(&buf) + defer repomdContent.Close() for _, file := range []struct { Name string @@ -272,10 +307,12 @@ func buildRepomd(ctx context.Context, pv *packages_model.PackageVersion, ownerID {"repomd.xml.asc", repomdAscContent}, } { _, err = packages_service.AddFileToPackageVersionInternal( + ctx, pv, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ - Filename: file.Name, + Filename: file.Name, + CompositeKey: group, }, Creator: user_model.NewGhostUser(), Data: file.Data, @@ -292,7 +329,7 @@ func buildRepomd(ctx context.Context, pv *packages_model.PackageVersion, ownerID } // https://docs.pulpproject.org/en/2.19/plugins/pulp_rpm/tech-reference/rpm.html#primary-xml -func buildPrimary(pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache) (*repoData, error) { +func buildPrimary(ctx context.Context, pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache, group string) (*repoData, error) { type Version struct { Epoch string `xml:"epoch,attr"` Version string `xml:"ver,attr"` @@ -372,7 +409,7 @@ func buildPrimary(pv *packages_model.PackageVersion, pfs []*packages_model.Packa files = append(files, f) } } - + packageVersion := fmt.Sprintf("%s-%s", pd.FileMetadata.Version, pd.FileMetadata.Release) packages = append(packages, &Package{ Type: "rpm", Name: pd.Package.Name, @@ -401,7 +438,7 @@ func buildPrimary(pv *packages_model.PackageVersion, pfs []*packages_model.Packa Archive: pd.FileMetadata.ArchiveSize, }, Location: Location{ - Href: fmt.Sprintf("package/%s/%s/%s", url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.FileMetadata.Architecture)), + Href: fmt.Sprintf("package/%s/%s/%s/%s", url.PathEscape(pd.Package.Name), url.PathEscape(packageVersion), url.PathEscape(pd.FileMetadata.Architecture), url.PathEscape(fmt.Sprintf("%s-%s.%s.rpm", pd.Package.Name, packageVersion, pd.FileMetadata.Architecture))), }, Format: Format{ License: pd.VersionMetadata.License, @@ -426,16 +463,16 @@ func buildPrimary(pv *packages_model.PackageVersion, pfs []*packages_model.Packa }) } - return addDataAsFileToRepo(pv, "primary", &Metadata{ + return addDataAsFileToRepo(ctx, pv, "primary", &Metadata{ Xmlns: "http://linux.duke.edu/metadata/common", XmlnsRpm: "http://linux.duke.edu/metadata/rpm", PackageCount: len(pfs), Packages: packages, - }) + }, group) } // https://docs.pulpproject.org/en/2.19/plugins/pulp_rpm/tech-reference/rpm.html#filelists-xml -func buildFilelists(pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache) (*repoData, error) { //nolint:dupl +func buildFilelists(ctx context.Context, pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache, group string) (*repoData, error) { //nolint:dupl type Version struct { Epoch string `xml:"epoch,attr"` Version string `xml:"ver,attr"` @@ -474,15 +511,15 @@ func buildFilelists(pv *packages_model.PackageVersion, pfs []*packages_model.Pac }) } - return addDataAsFileToRepo(pv, "filelists", &Filelists{ + return addDataAsFileToRepo(ctx, pv, "filelists", &Filelists{ Xmlns: "http://linux.duke.edu/metadata/other", PackageCount: len(pfs), Packages: packages, - }) + }, group) } // https://docs.pulpproject.org/en/2.19/plugins/pulp_rpm/tech-reference/rpm.html#other-xml -func buildOther(pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache) (*repoData, error) { //nolint:dupl +func buildOther(ctx context.Context, pv *packages_model.PackageVersion, pfs []*packages_model.PackageFile, c packageCache, group string) (*repoData, error) { //nolint:dupl type Version struct { Epoch string `xml:"epoch,attr"` Version string `xml:"ver,attr"` @@ -521,11 +558,11 @@ func buildOther(pv *packages_model.PackageVersion, pfs []*packages_model.Package }) } - return addDataAsFileToRepo(pv, "other", &Otherdata{ + return addDataAsFileToRepo(ctx, pv, "other", &Otherdata{ Xmlns: "http://linux.duke.edu/metadata/other", PackageCount: len(pfs), Packages: packages, - }) + }, group) } // writtenCounter counts all written bytes @@ -545,8 +582,10 @@ func (wc *writtenCounter) Written() int64 { return wc.written } -func addDataAsFileToRepo(pv *packages_model.PackageVersion, filetype string, obj any) (*repoData, error) { +func addDataAsFileToRepo(ctx context.Context, pv *packages_model.PackageVersion, filetype string, obj any, group string) (*repoData, error) { content, _ := packages_module.NewHashedBuffer() + defer content.Close() + gzw := gzip.NewWriter(content) wc := &writtenCounter{} h := sha256.New() @@ -565,10 +604,12 @@ func addDataAsFileToRepo(pv *packages_model.PackageVersion, filetype string, obj filename := filetype + ".xml.gz" _, err := packages_service.AddFileToPackageVersionInternal( + ctx, pv, &packages_service.PackageFileCreationInfo{ PackageFileInfo: packages_service.PackageFileInfo{ - Filename: filename, + Filename: filename, + CompositeKey: group, }, Creator: user_model.NewGhostUser(), Data: content, diff --git a/services/pull/check.go b/services/pull/check.go index e4a0f6b27bb..f4dd332b144 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -91,7 +92,7 @@ func CheckPullMergable(stdCtx context.Context, doer *user_model.User, perm *acce return nil } - if pr.IsWorkInProgress() { + if pr.IsWorkInProgress(ctx) { return ErrIsWorkInProgress } @@ -215,24 +216,26 @@ func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Com return nil, fmt.Errorf("GetFullCommitID(%s) in %s: %w", prHeadRef, pr.BaseRepo.FullName(), err) } + gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + return nil, fmt.Errorf("%-v OpenRepository: %w", pr.BaseRepo, err) + } + defer gitRepo.Close() + + objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) + // Get the commit from BaseBranch where the pull request got merged mergeCommit, _, err := git.NewCommand(ctx, "rev-list", "--ancestry-path", "--merges", "--reverse"). AddDynamicArguments(prHeadCommitID + ".." + pr.BaseBranch). RunStdString(&git.RunOpts{Dir: pr.BaseRepo.RepoPath()}) if err != nil { return nil, fmt.Errorf("git rev-list --ancestry-path --merges --reverse: %w", err) - } else if len(mergeCommit) < git.SHAFullLength { + } else if len(mergeCommit) < objectFormat.FullLength() { // PR was maybe fast-forwarded, so just use last commit of PR mergeCommit = prHeadCommitID } mergeCommit = strings.TrimSpace(mergeCommit) - gitRepo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) - if err != nil { - return nil, fmt.Errorf("%-v OpenRepository: %w", pr.BaseRepo, err) - } - defer gitRepo.Close() - commit, err := gitRepo.GetCommit(mergeCommit) if err != nil { return nil, fmt.Errorf("GetMergeCommit[%s]: %w", mergeCommit, err) @@ -360,7 +363,7 @@ func testPR(id int64) { if err := TestPatch(pr); err != nil { log.Error("testPatch[%-v]: %v", pr, err) pr.Status = issues_model.PullRequestStatusError - if err := pr.UpdateCols("status"); err != nil { + if err := pr.UpdateCols(ctx, "status"); err != nil { log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err) } return diff --git a/services/pull/check_test.go b/services/pull/check_test.go index 4a99859f5a3..dcf5f7b93a3 100644 --- a/services/pull/check_test.go +++ b/services/pull/check_test.go @@ -54,7 +54,7 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) { case id := <-idChan: assert.EqualValues(t, pr.ID, id) case <-time.After(time.Second): - assert.Fail(t, "Timeout: nothing was added to pullRequestQueue") + assert.FailNow(t, "Timeout: nothing was added to pullRequestQueue") } has, err = prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10)) diff --git a/services/pull/comment.go b/services/pull/comment.go index 14fba52f1e7..d538b118d5b 100644 --- a/services/pull/comment.go +++ b/services/pull/comment.go @@ -9,7 +9,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" ) @@ -17,8 +17,7 @@ import ( // isForcePush will be true if oldCommit isn't on the branch // Commit on baseBranch will skip func getCommitIDsFromRepo(ctx context.Context, repo *repo_model.Repository, oldCommitID, newCommitID, baseBranch string) (commitIDs []string, isForcePush bool, err error) { - repoPath := repo.RepoPath() - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repoPath) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return nil, false, err } diff --git a/services/pull/commit_status.go b/services/pull/commit_status.go index 39d60380ff2..aa1ad7cd665 100644 --- a/services/pull/commit_status.go +++ b/services/pull/commit_status.go @@ -11,6 +11,7 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/structs" @@ -34,9 +35,9 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus, } } - for _, commitStatus := range commitStatuses { + for _, gp := range requiredContextsGlob { var targetStatus structs.CommitStatusState - for _, gp := range requiredContextsGlob { + for _, commitStatus := range commitStatuses { if gp.Match(commitStatus.Context) { targetStatus = commitStatus.State matchedCount++ @@ -44,13 +45,21 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus, } } - if targetStatus != "" && targetStatus.NoBetterThan(returnedStatus) { + // If required rule not match any action, then it is pending + if targetStatus == "" { + if structs.CommitStatusPending.NoBetterThan(returnedStatus) { + returnedStatus = structs.CommitStatusPending + } + break + } + + if targetStatus.NoBetterThan(returnedStatus) { returnedStatus = targetStatus } } } - if matchedCount == 0 { + if matchedCount == 0 && returnedStatus == structs.CommitStatusSuccess { status := git_model.CalcCommitStatus(commitStatuses) if status != nil { return status.State @@ -116,7 +125,7 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR } // check if all required status checks are successful - headGitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.HeadRepo.RepoPath()) + headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) if err != nil { return "", errors.Wrap(err, "OpenRepository") } @@ -143,7 +152,7 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR return "", errors.Wrap(err, "LoadBaseRepo") } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptions{ListAll: true}) + commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll) if err != nil { return "", errors.Wrap(err, "GetLatestCommitStatus") } diff --git a/services/pull/commit_status_test.go b/services/pull/commit_status_test.go new file mode 100644 index 00000000000..592acdd55cd --- /dev/null +++ b/services/pull/commit_status_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Gitea Authors. +// All rights reserved. +// SPDX-License-Identifier: MIT + +package pull + +import ( + "testing" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestMergeRequiredContextsCommitStatus(t *testing.T) { + testCases := [][]*git_model.CommitStatus{ + { + {Context: "Build 1", State: structs.CommitStatusSuccess}, + {Context: "Build 2", State: structs.CommitStatusSuccess}, + {Context: "Build 3", State: structs.CommitStatusSuccess}, + }, + { + {Context: "Build 1", State: structs.CommitStatusSuccess}, + {Context: "Build 2", State: structs.CommitStatusSuccess}, + {Context: "Build 2t", State: structs.CommitStatusPending}, + }, + { + {Context: "Build 1", State: structs.CommitStatusSuccess}, + {Context: "Build 2", State: structs.CommitStatusSuccess}, + {Context: "Build 2t", State: structs.CommitStatusFailure}, + }, + { + {Context: "Build 1", State: structs.CommitStatusSuccess}, + {Context: "Build 2", State: structs.CommitStatusSuccess}, + {Context: "Build 2t", State: structs.CommitStatusSuccess}, + }, + { + {Context: "Build 1", State: structs.CommitStatusSuccess}, + {Context: "Build 2", State: structs.CommitStatusSuccess}, + {Context: "Build 2t", State: structs.CommitStatusSuccess}, + }, + } + testCasesRequiredContexts := [][]string{ + {"Build*"}, + {"Build*", "Build 2t*"}, + {"Build*", "Build 2t*"}, + {"Build*", "Build 2t*", "Build 3*"}, + {"Build*", "Build *", "Build 2t*", "Build 1*"}, + } + + testCasesExpected := []structs.CommitStatusState{ + structs.CommitStatusSuccess, + structs.CommitStatusPending, + structs.CommitStatusFailure, + structs.CommitStatusPending, + structs.CommitStatusSuccess, + } + + for i, commitStatuses := range testCases { + if MergeRequiredContextsCommitStatus(commitStatuses, testCasesRequiredContexts[i]) != testCasesExpected[i] { + assert.Fail(t, "Test case failed", "Test case %d failed", i+1) + } + } +} diff --git a/services/pull/lfs.go b/services/pull/lfs.go index 39433724684..ed03583d4f2 100644 --- a/services/pull/lfs.go +++ b/services/pull/lfs.go @@ -40,7 +40,7 @@ func LFSPush(ctx context.Context, tmpBasePath, mergeHeadSHA, mergeBaseSHA string // 6. Take the output of cat-file --batch and check if each file in turn // to see if they're pointers to files in the LFS store associated with // the head repo and add them to the base repo if so - go createLFSMetaObjectsFromCatFileBatch(catFileBatchReader, &wg, pr) + go createLFSMetaObjectsFromCatFileBatch(db.DefaultContext, catFileBatchReader, &wg, pr) // 5. Take the shas of the blobs and batch read them go pipeline.CatFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, tmpBasePath) @@ -68,7 +68,7 @@ func LFSPush(ctx context.Context, tmpBasePath, mergeHeadSHA, mergeBaseSHA string return nil } -func createLFSMetaObjectsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pr *issues_model.PullRequest) { +func createLFSMetaObjectsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pr *issues_model.PullRequest) { defer wg.Done() defer catFileBatchReader.Close() @@ -116,7 +116,7 @@ func createLFSMetaObjectsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg } // Then we need to check that this pointer is in the db - if _, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, pr.HeadRepoID, pointer.Oid); err != nil { + if _, err := git_model.GetLFSMetaObjectByOid(ctx, pr.HeadRepoID, pointer.Oid); err != nil { if err == git_model.ErrLFSObjectNotExist { log.Warn("During merge of: %d in %-v, there is a pointer to LFS Oid: %s which although present in the LFS store is not associated with the head repo %-v", pr.Index, pr.BaseRepo, pointer.Oid, pr.HeadRepo) continue @@ -127,9 +127,7 @@ func createLFSMetaObjectsFromCatFileBatch(catFileBatchReader *io.PipeReader, wg // OK we have a pointer that is associated with the head repo // and is actually a file in the LFS // Therefore it should be associated with the base repo - meta := &git_model.LFSMetaObject{Pointer: pointer} - meta.RepositoryID = pr.BaseRepoID - if _, err := git_model.NewLFSMetaObject(db.DefaultContext, meta); err != nil { + if _, err := git_model.NewLFSMetaObject(ctx, pr.BaseRepoID, pointer); err != nil { _ = catFileBatchReader.CloseWithError(err) break } diff --git a/services/pull/main_test.go b/services/pull/main_test.go index f5297354d65..efbb63a36ec 100644 --- a/services/pull/main_test.go +++ b/services/pull/main_test.go @@ -5,7 +5,6 @@ package pull import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -14,7 +13,5 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/pull/merge.go b/services/pull/merge.go index 185060cbac5..e37540a96fc 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -44,6 +44,9 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue if err := pr.LoadIssue(ctx); err != nil { return "", "", err } + if err := pr.Issue.LoadPoster(ctx); err != nil { + return "", "", err + } isExternalTracker := pr.BaseRepo.UnitEnabled(ctx, unit.TypeExternalTracker) issueReference := "#" @@ -264,6 +267,10 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use if err := doMergeStyleSquash(mergeCtx, message); err != nil { return "", err } + case repo_model.MergeStyleFastForwardOnly: + if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil { + return "", err + } default: return "", models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle} } @@ -374,6 +381,13 @@ func runMergeCommand(ctx *mergeContext, mergeStyle repo_model.MergeStyle, cmd *g StdErr: ctx.errbuf.String(), Err: err, } + } else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(ctx.errbuf.String(), "Not possible to fast-forward, aborting") { + log.Debug("MergeDivergingFastForwardOnly %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) + return models.ErrMergeDivergingFastForwardOnly{ + StdOut: ctx.outbuf.String(), + StdErr: ctx.errbuf.String(), + Err: err, + } } log.Error("git merge %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String()) @@ -464,11 +478,11 @@ func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullReques } // MergedManually mark pr as merged manually -func MergedManually(pr *issues_model.PullRequest, doer *user_model.User, baseGitRepo *git.Repository, commitID string) error { +func MergedManually(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, baseGitRepo *git.Repository, commitID string) error { pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) - if err := db.WithTx(db.DefaultContext, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { if err := pr.LoadBaseRepo(ctx); err != nil { return err } @@ -483,7 +497,8 @@ func MergedManually(pr *issues_model.PullRequest, doer *user_model.User, baseGit return models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: repo_model.MergeStyleManuallyMerged} } - if len(commitID) < git.SHAFullLength { + objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) + if len(commitID) != objectFormat.FullLength() { return fmt.Errorf("Wrong commit ID") } diff --git a/services/pull/merge_ff_only.go b/services/pull/merge_ff_only.go new file mode 100644 index 00000000000..f57c732104c --- /dev/null +++ b/services/pull/merge_ff_only.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pull + +import ( + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" +) + +// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch) +func doMergeStyleFastForwardOnly(ctx *mergeContext) error { + cmd := git.NewCommand(ctx, "merge", "--ff-only").AddDynamicArguments(trackingBranch) + if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil { + log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err) + return err + } + + return nil +} diff --git a/services/pull/merge_merge.go b/services/pull/merge_merge.go index 0f7664297aa..bf56c071db0 100644 --- a/services/pull/merge_merge.go +++ b/services/pull/merge_merge.go @@ -9,7 +9,7 @@ import ( "code.gitea.io/gitea/modules/log" ) -// doMergeStyleMerge merges the tracking into the current HEAD - which is assumed to tbe staging branch (equal to the pr.BaseBranch) +// doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch) func doMergeStyleMerge(ctx *mergeContext, message string) error { cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch) if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil { diff --git a/services/pull/merge_rebase.go b/services/pull/merge_rebase.go index a88f805ef02..ecf376220e2 100644 --- a/services/pull/merge_rebase.go +++ b/services/pull/merge_rebase.go @@ -9,6 +9,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" ) @@ -57,7 +58,7 @@ func doMergeRebaseFastForward(ctx *mergeContext) error { } // Original repo to read template from. - baseGitRepo, err := git.OpenRepository(ctx, ctx.pr.BaseRepo.RepoPath()) + baseGitRepo, err := gitrepo.OpenRepository(ctx, ctx.pr.BaseRepo) if err != nil { log.Error("Unable to get Git repo for rebase: %v", err) return err diff --git a/services/pull/merge_squash.go b/services/pull/merge_squash.go index f52a2301d90..197d8102dd9 100644 --- a/services/pull/merge_squash.go +++ b/services/pull/merge_squash.go @@ -10,6 +10,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) @@ -24,7 +25,7 @@ func getAuthorSignatureSquash(ctx *mergeContext) (*git.Signature, error) { // Try to get an signature from the same user in one of the commits, as the // poster email might be private or commits might have a different signature // than the primary email address of the poster. - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, ctx.tmpBasePath) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpenPath(ctx, ctx.tmpBasePath) if err != nil { log.Error("%-v Unable to open base repository: %v", ctx.pr, err) return nil, err diff --git a/services/pull/patch.go b/services/pull/patch.go index 688cbcc027d..12b79a06253 100644 --- a/services/pull/patch.go +++ b/services/pull/patch.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -35,7 +36,7 @@ func DownloadDiffOrPatch(ctx context.Context, pr *issues_model.PullRequest, w io return err } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) if err != nil { return fmt.Errorf("OpenRepository: %w", err) } @@ -129,6 +130,7 @@ func (e *errMergeConflict) Error() string { func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, gitRepo *git.Repository) error { log.Trace("Attempt to merge:\n%v", file) + switch { case file.stage1 != nil && (file.stage2 == nil || file.stage3 == nil): // 1. Deleted in one or both: diff --git a/services/pull/pull.go b/services/pull/pull.go index 8e57aebe3d8..185a1895c9f 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -4,6 +4,7 @@ package pull import ( + "bytes" "context" "fmt" "io" @@ -20,8 +21,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" - gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -29,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sync" "code.gitea.io/gitea/modules/util" + gitea_context "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" notify_service "code.gitea.io/gitea/services/notify" ) @@ -38,6 +40,14 @@ var pullWorkingPool = sync.NewExclusivePool() // NewPullRequest creates new pull request with labels for repository. func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { + if err := issue.LoadPoster(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) { + return user_model.ErrBlockedUser + } + prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) if err != nil { if !git_model.IsErrBranchNotExist(err) { @@ -61,12 +71,13 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss assigneeCommentMap := make(map[int64]*issues_model.Comment) // add first push codes comment - baseGitRepo, err := git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { return err } defer baseGitRepo.Close() + var reviewNotifers []*issue_service.ReviewRequestNotifier if err := db.WithTx(ctx, func(ctx context.Context) error { if err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr); err != nil { return err @@ -125,8 +136,9 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss return err } - if !pr.IsWorkInProgress() { - if err := issues_model.PullRequestCodeOwnersReview(ctx, issue, pr); err != nil { + if !pr.IsWorkInProgress(ctx) { + reviewNotifers, err = issue_service.PullRequestCodeOwnersReview(ctx, issue, pr) + if err != nil { return err } } @@ -140,11 +152,12 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss } baseGitRepo.Close() // close immediately to avoid notifications will open the repository again + issue_service.ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifers) + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content) if err != nil { return err } - notify_service.NewPullRequest(ctx, pr, mentions) if len(issue.Labels) > 0 { notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) @@ -152,14 +165,12 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss if issue.Milestone != nil { notify_service.IssueChangeMilestone(ctx, issue.Poster, issue, 0) } - if len(assigneeIDs) > 0 { - for _, assigneeID := range assigneeIDs { - assignee, err := user_model.GetUserByID(ctx, assigneeID) - if err != nil { - return ErrDependenciesLeft - } - notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID]) + for _, assigneeID := range assigneeIDs { + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + return ErrDependenciesLeft } + notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID]) } return nil @@ -270,9 +281,9 @@ func checkForInvalidation(ctx context.Context, requests issues_model.PullRequest if err != nil { return fmt.Errorf("GetRepositoryByIDCtx: %w", err) } - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { - return fmt.Errorf("git.OpenRepository: %w", err) + return fmt.Errorf("gitrepo.OpenRepository: %w", err) } go func() { // FIXME: graceful: We need to tell the manager we're doing something... @@ -329,14 +340,15 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, } if err == nil { for _, pr := range prs { - if newCommitID != "" && newCommitID != git.EmptySHA { + objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) + if newCommitID != "" && newCommitID != objectFormat.EmptyObjectID().String() { changed, err := checkIfPRContentChanged(ctx, pr, oldCommitID, newCommitID) if err != nil { log.Error("checkIfPRContentChanged: %v", err) } if changed { // Mark old reviews as stale if diff to mergebase has changed - if err := issues_model.MarkReviewsAsStale(pr.IssueID); err != nil { + if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil { log.Error("MarkReviewsAsStale: %v", err) } @@ -351,7 +363,7 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, } } } - if err := issues_model.MarkReviewsAsNotStale(pr.IssueID, newCommitID); err != nil { + if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, newCommitID); err != nil { log.Error("MarkReviewsAsNotStale: %v", err) } divergence, err := GetDiverging(ctx, pr) @@ -423,9 +435,11 @@ func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, return false, fmt.Errorf("unable to open pipe for to run diff: %w", err) } + stderr := new(bytes.Buffer) if err := cmd.Run(&git.RunOpts{ Dir: prCtx.tmpBasePath, Stdout: stdoutWriter, + Stderr: stderr, PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { _ = stdoutWriter.Close() defer func() { @@ -437,6 +451,7 @@ func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, if err == util.ErrNotEmpty { return true, nil } + err = git.ConcatenateError(err, stderr.String()) log.Error("Unable to run diff on %s %s %s in tempRepo for PR[%d]%s/%s...%s/%s: Error: %v", newCommitID, oldCommitID, base, @@ -511,6 +526,25 @@ func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, pre return nil } +// UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head so that it will be dependent by other operations +func UpdatePullsRefs(ctx context.Context, repo *repo_model.Repository, update *repo_module.PushUpdateOptions) { + branch := update.RefFullName.BranchName() + // GetUnmergedPullRequestsByHeadInfo() only return open and unmerged PR. + prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch) + if err != nil { + log.Error("Find pull requests [head_repo_id: %d, head_branch: %s]: %v", repo.ID, branch, err) + } else { + for _, pr := range prs { + log.Trace("Updating PR[%d]: composing new test task", pr.ID) + if pr.Flow == issues_model.PullRequestFlowGithub { + if err := PushToBaseRepo(ctx, pr); err != nil { + log.Error("PushToBaseRepo: %v", err) + } + } + } + } +} + // UpdateRef update refs/pull/id/head directly for agit flow pull request func UpdateRef(ctx context.Context, pr *issues_model.PullRequest) (err error) { log.Trace("UpdateRef[%d]: upgate pull request ref in base repo '%s'", pr.ID, pr.GetGitRefName()) @@ -543,6 +577,43 @@ func (errs errlist) Error() string { return "" } +// RetargetChildrenOnMerge retarget children pull requests on merge if possible +func RetargetChildrenOnMerge(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) error { + if setting.Repository.PullRequest.RetargetChildrenOnMerge && pr.BaseRepoID == pr.HeadRepoID { + return RetargetBranchPulls(ctx, doer, pr.HeadRepoID, pr.HeadBranch, pr.BaseBranch) + } + return nil +} + +// RetargetBranchPulls change target branch for all pull requests whose base branch is the branch +// Both branch and targetBranch must be in the same repo (for security reasons) +func RetargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch, targetBranch string) error { + prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch) + if err != nil { + return err + } + + if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + return err + } + + var errs errlist + for _, pr := range prs { + if err = pr.Issue.LoadRepo(ctx); err != nil { + errs = append(errs, err) + } else if err = ChangeTargetBranch(ctx, pr, doer, targetBranch); err != nil && + !issues_model.IsErrIssueIsClosed(err) && !models.IsErrPullRequestHasMerged(err) && + !issues_model.IsErrPullRequestAlreadyExists(err) { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errs + } + return nil +} + // CloseBranchPulls close all the pull requests who's head branch is the branch func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch string) error { prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repoID, branch) @@ -574,7 +645,7 @@ func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, // CloseRepoBranchesPulls close all pull requests which head branches are in the given repository, but only whose base repo is not in the given repository func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) error { - branches, _, err := git.GetBranchesByPath(ctx, repo.RepoPath(), 0, 0) + branches, _, err := gitrepo.GetBranchesByPath(ctx, repo, 0, 0) if err != nil { return err } @@ -631,7 +702,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ } } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.HeadRepo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) if err != nil { log.Error("Unable to open head repository: Error: %v", err) return "" @@ -805,7 +876,7 @@ func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList } gitRepo, ok := gitRepos[issue.RepoID] if !ok { - gitRepo, err = git.OpenRepository(ctx, issue.Repo.RepoPath()) + gitRepo, err = gitrepo.OpenRepository(ctx, issue.Repo) if err != nil { log.Error("Cannot open git repository %-v for issue #%d[%d]. Error: %v", issue.Repo, issue.Index, issue.ID, err) continue @@ -813,7 +884,7 @@ func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList gitRepos[issue.RepoID] = gitRepo } - statuses, lastStatus, err := getAllCommitStatus(gitRepo, issue.PullRequest) + statuses, lastStatus, err := getAllCommitStatus(ctx, gitRepo, issue.PullRequest) if err != nil { log.Error("getAllCommitStatus: cant get commit statuses of pull [%d]: %v", issue.PullRequest.ID, err) continue @@ -825,13 +896,13 @@ func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList } // getAllCommitStatus get pr's commit statuses. -func getAllCommitStatus(gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) { +func getAllCommitStatus(ctx context.Context, gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) { sha, shaErr := gitRepo.GetRefCommitID(pr.GetGitRefName()) if shaErr != nil { return nil, nil, shaErr } - statuses, _, err = git_model.GetLatestCommitStatus(db.DefaultContext, pr.BaseRepo.ID, sha, db.ListOptions{ListAll: true}) + statuses, _, err = git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll) lastStatus = git_model.CalcCommitStatus(statuses) return statuses, lastStatus, err } @@ -842,7 +913,7 @@ func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, br if err = pr.LoadBaseRepo(ctx); err != nil { return false, err } - baseGitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) if err != nil { return false, err } @@ -862,7 +933,7 @@ func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, br } else { var closer io.Closer - headGitRepo, closer, err = git.RepositoryFromContextOrOpen(ctx, pr.HeadRepo.RepoPath()) + headGitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) if err != nil { return false, err } @@ -918,12 +989,12 @@ func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]Co for _, commit := range prInfo.Commits { var committerOrAuthorName string var commitTime time.Time - if commit.Committer != nil { - committerOrAuthorName = commit.Committer.Name - commitTime = commit.Committer.When - } else { + if commit.Author != nil { committerOrAuthorName = commit.Author.Name commitTime = commit.Author.When + } else { + committerOrAuthorName = commit.Committer.Name + commitTime = commit.Committer.When } commits = append(commits, CommitInfo{ diff --git a/services/pull/pull_test.go b/services/pull/pull_test.go index d63227a7d5e..787910bf760 100644 --- a/services/pull/pull_test.go +++ b/services/pull/pull_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "github.com/stretchr/testify/assert" ) @@ -41,7 +42,7 @@ func TestPullRequest_GetDefaultMergeMessage_InternalTracker(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) - gitRepo, err := git.OpenRepository(git.DefaultContext, pr.BaseRepo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, pr.BaseRepo) assert.NoError(t, err) defer gitRepo.Close() @@ -71,7 +72,7 @@ func TestPullRequest_GetDefaultMergeMessage_ExternalTracker(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2, BaseRepo: baseRepo}) assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) - gitRepo, err := git.OpenRepository(git.DefaultContext, pr.BaseRepo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, pr.BaseRepo) assert.NoError(t, err) defer gitRepo.Close() diff --git a/services/pull/review.go b/services/pull/review.go index c82d1341b6d..5bf1991d134 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -16,7 +16,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" @@ -24,6 +26,23 @@ import ( var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`) +// ErrDismissRequestOnClosedPR represents an error when an user tries to dismiss a review associated to a closed or merged PR. +type ErrDismissRequestOnClosedPR struct{} + +// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR. +func IsErrDismissRequestOnClosedPR(err error) bool { + _, ok := err.(ErrDismissRequestOnClosedPR) + return ok +} + +func (err ErrDismissRequestOnClosedPR) Error() string { + return "can't dismiss a review associated to a closed or merged PR" +} + +func (err ErrDismissRequestOnClosedPR) Unwrap() error { + return util.ErrPermissionDenied +} + // checkInvalidation checks if the line of code comment got changed by another commit. // If the line got changed the comment is going to be invalidated. func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error { @@ -49,16 +68,14 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis return nil } issueIDs := prs.GetIssueIDs() - var codeComments []*issues_model.Comment - if err := db.Find(ctx, &issues_model.FindCommentsOptions{ - ListOptions: db.ListOptions{ - ListAll: true, - }, + codeComments, err := db.Find[issues_model.Comment](ctx, issues_model.FindCommentsOptions{ + ListOptions: db.ListOptionsAll, Type: issues_model.CommentTypeCode, - Invalidated: util.OptionalBoolFalse, + Invalidated: optional.Some(false), IssueIDs: issueIDs, - }, &codeComments); err != nil { + }) + if err != nil { return fmt.Errorf("find code comments: %v", err) } for _, comment := range codeComments { @@ -70,7 +87,7 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis } // CreateCodeComment creates a comment on the code line -func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string) (*issues_model.Comment, error) { +func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) { var ( existsReview bool err error @@ -84,7 +101,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. if !pendingReview && replyReviewID != 0 { // It's not part of a review; maybe a reply to a review comment or a single comment. // Check if there are reviews for that line already; if there are, this is a reply - if existsReview, err = issues_model.ReviewExists(issue, treePath, line); err != nil { + if existsReview, err = issues_model.ReviewExists(ctx, issue, treePath, line); err != nil { return nil, err } } @@ -103,6 +120,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. treePath, line, replyReviewID, + attachments, ) if err != nil { return nil, err @@ -143,6 +161,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. treePath, line, review.ID, + attachments, ) if err != nil { return nil, err @@ -161,7 +180,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. } // createCodeComment creates a plain code comment at the specified line / path -func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64) (*issues_model.Comment, error) { +func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) { var commitID, patch string if err := issue.LoadPullRequest(ctx); err != nil { return nil, fmt.Errorf("LoadPullRequest: %w", err) @@ -170,7 +189,7 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo if err := pr.LoadBaseRepo(ctx); err != nil { return nil, fmt.Errorf("LoadBaseRepo: %w", err) } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) if err != nil { return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err) } @@ -259,16 +278,17 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo ReviewID: reviewID, Patch: patch, Invalidated: invalidated, + Attachments: attachments, }) } // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, reviewType issues_model.ReviewType, content, commitID string, attachmentUUIDs []string) (*issues_model.Review, *issues_model.Comment, error) { - pr, err := issue.GetPullRequest() - if err != nil { + if err := issue.LoadPullRequest(ctx); err != nil { return nil, nil, err } + pr := issue.PullRequest var stale bool if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject { stale = false @@ -288,7 +308,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos } } - review, comm, err := issues_model.SubmitReview(doer, issue, reviewType, content, commitID, stale, attachmentUUIDs) + review, comm, err := issues_model.SubmitReview(ctx, doer, issue, reviewType, content, commitID, stale, attachmentUUIDs) if err != nil { return nil, nil, err } @@ -318,12 +338,10 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos // DismissApprovalReviews dismiss all approval reviews because of new commits func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest) error { reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{ - ListOptions: db.ListOptions{ - ListAll: true, - }, - IssueID: pull.IssueID, - Type: issues_model.ReviewTypeApprove, - Dismissed: util.OptionalBoolFalse, + ListOptions: db.ListOptionsAll, + IssueID: pull.IssueID, + Type: issues_model.ReviewTypeApprove, + Dismissed: optional.Some(false), }) if err != nil { return err @@ -382,6 +400,21 @@ func DismissReview(ctx context.Context, reviewID, repoID int64, message string, return nil, fmt.Errorf("reviews's repository is not the same as the one we expect") } + issue := review.Issue + + if issue.IsClosed { + return nil, ErrDismissRequestOnClosedPR{} + } + + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, err + } + if issue.PullRequest.HasMerged { + return nil, ErrDismissRequestOnClosedPR{} + } + } + if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil { return nil, err } @@ -390,7 +423,7 @@ func DismissReview(ctx context.Context, reviewID, repoID int64, message string, reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{ IssueID: review.IssueID, ReviewerID: review.ReviewerID, - Dismissed: util.OptionalBoolFalse, + Dismissed: optional.Some(false), }) if err != nil { return nil, err diff --git a/services/pull/review_test.go b/services/pull/review_test.go new file mode 100644 index 00000000000..3bce1e523d7 --- /dev/null +++ b/services/pull/review_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pull_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" +) + +func TestDismissReview(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{}) + assert.NoError(t, pull.LoadIssue(db.DefaultContext)) + issue := pull.Issue + assert.NoError(t, issue.LoadRepo(db.DefaultContext)) + reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + review, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{ + Issue: issue, + Reviewer: reviewer, + Type: issues_model.ReviewTypeReject, + }) + + assert.NoError(t, err) + issue.IsClosed = true + pull.HasMerged = false + assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed")) + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + _, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false) + assert.Error(t, err) + assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err)) + + pull.HasMerged = true + pull.Issue.IsClosed = false + assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed")) + assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged")) + _, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false) + assert.Error(t, err) + assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err)) +} diff --git a/services/pull/temp_repo.go b/services/pull/temp_repo.go index db32940e383..36bdbde55c7 100644 --- a/services/pull/temp_repo.go +++ b/services/pull/temp_repo.go @@ -94,7 +94,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest) baseRepoPath := pr.BaseRepo.RepoPath() headRepoPath := pr.HeadRepo.RepoPath() - if err := git.InitRepository(ctx, tmpBasePath, false); err != nil { + if err := git.InitRepository(ctx, tmpBasePath, false, pr.BaseRepo.ObjectFormatName); err != nil { log.Error("Unable to init tmpBasePath for %-v: %v", pr, err) cancel() return nil, nil, err @@ -168,11 +168,12 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest) } trackingBranch := "tracking" + objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) // Fetch head branch var headBranch string if pr.Flow == issues_model.PullRequestFlowGithub { headBranch = git.BranchPrefix + pr.HeadBranch - } else if len(pr.HeadCommitID) == git.SHAFullLength { // for not created pull request + } else if len(pr.HeadCommitID) == objectFormat.FullLength() { // for not created pull request headBranch = pr.HeadCommitID } else { headBranch = pr.GetGitRefName() diff --git a/services/release/release.go b/services/release/release.go index ac3acc90859..ba5fd1dd986 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -16,6 +16,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/storage" @@ -25,6 +27,16 @@ import ( ) func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) { + err := rel.LoadAttributes(ctx) + if err != nil { + return false, err + } + + err = rel.Repo.MustNotBeArchived() + if err != nil { + return false, err + } + var created bool // Only actual create when publish. if !rel.IsDraft { @@ -76,16 +88,17 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel created = true rel.LowerTagName = strings.ToLower(rel.TagName) + objectFormat := git.ObjectFormatFromName(rel.Repo.ObjectFormatName) commits := repository.NewPushCommits() commits.HeadCommit = repository.CommitToPushCommit(commit) - commits.CompareURL = rel.Repo.ComposeCompareURL(git.EmptySHA, commit.ID.String()) + commits.CompareURL = rel.Repo.ComposeCompareURL(objectFormat.EmptyObjectID().String(), commit.ID.String()) refFullName := git.RefNameFromTag(rel.TagName) notify_service.PushCommits( ctx, rel.Publisher, rel.Repo, &repository.PushUpdateOptions{ RefFullName: refFullName, - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: commit.ID.String(), }, commits) notify_service.CreateRef(ctx, rel.Publisher, rel.Repo, refFullName, commit.ID.String()) @@ -156,7 +169,7 @@ func CreateNewTag(ctx context.Context, doer *user_model.User, repo *repo_model.R } } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return err } @@ -185,7 +198,7 @@ func CreateNewTag(ctx context.Context, doer *user_model.User, repo *repo_model.R // addAttachmentUUIDs accept a slice of new created attachments' uuids which will be reassigned release_id as the created release // delAttachmentUUIDs accept a slice of attachments' uuids which will be deleted from the release // editAttachments accept a map of attachment uuid to new attachment name which will be updated with attachments. -func UpdateRelease(doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, +func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, addAttachmentUUIDs, delAttachmentUUIDs []string, editAttachments map[string]string, ) error { if rel.ID == 0 { @@ -197,7 +210,7 @@ func UpdateRelease(doer *user_model.User, gitRepo *git.Repository, rel *repo_mod } rel.LowerTagName = strings.ToLower(rel.TagName) - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } @@ -278,30 +291,18 @@ func UpdateRelease(doer *user_model.User, gitRepo *git.Repository, rel *repo_mod } } - if !isCreated { - notify_service.UpdateRelease(gitRepo.Ctx, doer, rel) - return nil - } - if !rel.IsDraft { + if !isCreated { + notify_service.UpdateRelease(gitRepo.Ctx, doer, rel) + return nil + } notify_service.NewRelease(gitRepo.Ctx, rel) } - return nil } // DeleteReleaseByID deletes a release and corresponding Git tag by given ID. -func DeleteReleaseByID(ctx context.Context, id int64, doer *user_model.User, delTag bool) error { - rel, err := repo_model.GetReleaseByID(ctx, id) - if err != nil { - return fmt.Errorf("GetReleaseByID: %w", err) - } - - repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID) - if err != nil { - return fmt.Errorf("GetRepositoryByID: %w", err) - } - +func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *repo_model.Release, doer *user_model.User, delTag bool) error { if delTag { protectedTags, err := git_model.GetProtectedTags(ctx, rel.RepoID) if err != nil { @@ -325,28 +326,29 @@ func DeleteReleaseByID(ctx context.Context, id int64, doer *user_model.User, del } refName := git.RefNameFromTag(rel.TagName) + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) notify_service.PushCommits( ctx, doer, repo, &repository.PushUpdateOptions{ RefFullName: refName, OldCommitID: rel.Sha1, - NewCommitID: git.EmptySHA, + NewCommitID: objectFormat.EmptyObjectID().String(), }, repository.NewPushCommits()) notify_service.DeleteRef(ctx, doer, repo, refName) - if err := repo_model.DeleteReleaseByID(ctx, id); err != nil { + if _, err := db.DeleteByID[repo_model.Release](ctx, rel.ID); err != nil { return fmt.Errorf("DeleteReleaseByID: %w", err) } } else { rel.IsTag = true - if err = repo_model.UpdateRelease(ctx, rel); err != nil { + if err := repo_model.UpdateRelease(ctx, rel); err != nil { return fmt.Errorf("Update: %w", err) } } rel.Repo = repo - if err = rel.LoadAttributes(ctx); err != nil { + if err := rel.LoadAttributes(ctx); err != nil { return fmt.Errorf("LoadAttributes: %w", err) } @@ -361,7 +363,13 @@ func DeleteReleaseByID(ctx context.Context, id int64, doer *user_model.User, del } } - notify_service.DeleteRelease(ctx, doer, rel) - + if !rel.IsDraft { + notify_service.DeleteRelease(ctx, doer, rel) + } return nil } + +// Init start release service +func Init() error { + return initTagSyncQueue(graceful.GetManager().ShutdownContext()) +} diff --git a/services/release/release_test.go b/services/release/release_test.go index 0732dbc54d3..3d0681f1e17 100644 --- a/services/release/release_test.go +++ b/services/release/release_test.go @@ -4,7 +4,6 @@ package release import ( - "path/filepath" "strings" "testing" "time" @@ -14,6 +13,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/services/attachment" _ "code.gitea.io/gitea/models/actions" @@ -22,9 +22,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestRelease_Create(t *testing.T) { @@ -32,9 +30,8 @@ func TestRelease_Create(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - repoPath := repo_model.RepoPath(user.Name, repo.Name) - gitRepo, err := git.OpenRepository(git.DefaultContext, repoPath) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) assert.NoError(t, err) defer gitRepo.Close() @@ -110,7 +107,7 @@ func TestRelease_Create(t *testing.T) { testPlayload := "testtest" - attach, err := attachment.NewAttachment(&repo_model.Attachment{ + attach, err := attachment.NewAttachment(db.DefaultContext, &repo_model.Attachment{ RepoID: repo.ID, UploaderID: user.ID, Name: "test.txt", @@ -138,9 +135,8 @@ func TestRelease_Update(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - repoPath := repo_model.RepoPath(user.Name, repo.Name) - gitRepo, err := git.OpenRepository(git.DefaultContext, repoPath) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) assert.NoError(t, err) defer gitRepo.Close() @@ -158,12 +154,12 @@ func TestRelease_Update(t *testing.T) { IsPrerelease: false, IsTag: false, }, nil, "")) - release, err := repo_model.GetRelease(repo.ID, "v1.1.1") + release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1") assert.NoError(t, err) releaseCreatedUnix := release.CreatedUnix time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp release.Note = "Changed note" - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, nil, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil)) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) assert.NoError(t, err) assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) @@ -182,12 +178,12 @@ func TestRelease_Update(t *testing.T) { IsPrerelease: false, IsTag: false, }, nil, "")) - release, err = repo_model.GetRelease(repo.ID, "v1.2.1") + release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1") assert.NoError(t, err) releaseCreatedUnix = release.CreatedUnix time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp release.Title = "Changed title" - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, nil, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil)) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) assert.NoError(t, err) assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) @@ -206,13 +202,13 @@ func TestRelease_Update(t *testing.T) { IsPrerelease: true, IsTag: false, }, nil, "")) - release, err = repo_model.GetRelease(repo.ID, "v1.3.1") + release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1") assert.NoError(t, err) releaseCreatedUnix = release.CreatedUnix time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp release.Title = "Changed title" release.Note = "Changed note" - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, nil, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil)) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) assert.NoError(t, err) assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) @@ -237,21 +233,21 @@ func TestRelease_Update(t *testing.T) { release.IsDraft = false tagName := release.TagName - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, nil, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil)) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) assert.NoError(t, err) assert.Equal(t, tagName, release.TagName) // Add new attachments samplePayload := "testtest" - attach, err := attachment.NewAttachment(&repo_model.Attachment{ + attach, err := attachment.NewAttachment(db.DefaultContext, &repo_model.Attachment{ RepoID: repo.ID, UploaderID: user.ID, Name: "test.txt", }, strings.NewReader(samplePayload), int64(len([]byte(samplePayload)))) assert.NoError(t, err) - assert.NoError(t, UpdateRelease(user, gitRepo, release, []string{attach.UUID}, nil, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, []string{attach.UUID}, nil, nil)) assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) assert.Len(t, release.Attachments, 1) assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) @@ -259,7 +255,7 @@ func TestRelease_Update(t *testing.T) { assert.EqualValues(t, attach.Name, release.Attachments[0].Name) // update the attachment name - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, nil, map[string]string{ + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, map[string]string{ attach.UUID: "test2.txt", })) release.Attachments = nil @@ -270,7 +266,7 @@ func TestRelease_Update(t *testing.T) { assert.EqualValues(t, "test2.txt", release.Attachments[0].Name) // delete the attachment - assert.NoError(t, UpdateRelease(user, gitRepo, release, nil, []string{attach.UUID}, nil)) + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, []string{attach.UUID}, nil)) release.Attachments = nil assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) assert.Empty(t, release.Attachments) @@ -281,9 +277,8 @@ func TestRelease_createTag(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - repoPath := repo_model.RepoPath(user.Name, repo.Name) - gitRepo, err := git.OpenRepository(git.DefaultContext, repoPath) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) assert.NoError(t, err) defer gitRepo.Close() diff --git a/services/release/tag.go b/services/release/tag.go new file mode 100644 index 00000000000..dae2b70f76b --- /dev/null +++ b/services/release/tag.go @@ -0,0 +1,61 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "context" + "errors" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/queue" + repo_module "code.gitea.io/gitea/modules/repository" + + "xorm.io/builder" +) + +type TagSyncOptions struct { + RepoID int64 +} + +// tagSyncQueue represents a queue to handle tag sync jobs. +var tagSyncQueue *queue.WorkerPoolQueue[*TagSyncOptions] + +func handlerTagSync(items ...*TagSyncOptions) []*TagSyncOptions { + for _, opts := range items { + err := repo_module.SyncRepoTags(graceful.GetManager().ShutdownContext(), opts.RepoID) + if err != nil { + log.Error("syncRepoTags [%d] failed: %v", opts.RepoID, err) + } + } + return nil +} + +func addRepoToTagSyncQueue(repoID int64) error { + return tagSyncQueue.Push(&TagSyncOptions{ + RepoID: repoID, + }) +} + +func initTagSyncQueue(ctx context.Context) error { + tagSyncQueue = queue.CreateUniqueQueue(ctx, "tag_sync", handlerTagSync) + if tagSyncQueue == nil { + return errors.New("unable to create tag_sync queue") + } + go graceful.GetManager().RunWithCancel(tagSyncQueue) + + return nil +} + +func AddAllRepoTagsToSyncQueue(ctx context.Context) error { + if err := db.Iterate(ctx, builder.Eq{"is_empty": false}, func(ctx context.Context, repo *repo_model.Repository) error { + return addRepoToTagSyncQueue(repo.ID) + }); err != nil { + return fmt.Errorf("run sync all tags failed: %v", err) + } + return nil +} diff --git a/services/repository/adopt.go b/services/repository/adopt.go index 00dce7295ed..b337eac38ac 100644 --- a/services/repository/adopt.go +++ b/services/repository/adopt.go @@ -17,7 +17,9 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -125,35 +127,26 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r repo.IsEmpty = false - // Don't bother looking this repo in the context it won't be there - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) - if err != nil { - return fmt.Errorf("openRepository: %w", err) - } - defer gitRepo.Close() - if len(defaultBranch) > 0 { repo.DefaultBranch = defaultBranch - if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { return fmt.Errorf("setDefaultBranch: %w", err) } } else { - repo.DefaultBranch, err = gitRepo.GetDefaultBranch() + repo.DefaultBranch, err = gitrepo.GetDefaultBranch(ctx, repo) if err != nil { repo.DefaultBranch = setting.Repository.DefaultBranch - if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { return fmt.Errorf("setDefaultBranch: %w", err) } } } branches, _ := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ - RepoID: repo.ID, - ListOptions: db.ListOptions{ - ListAll: true, - }, - IsDeletedBranch: util.OptionalBoolFalse, + RepoID: repo.ID, + ListOptions: db.ListOptionsAll, + IsDeletedBranch: optional.Some(false), }) found := false @@ -186,7 +179,7 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r repo.DefaultBranch = setting.Repository.DefaultBranch } - if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { return fmt.Errorf("setDefaultBranch: %w", err) } } @@ -195,7 +188,14 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r return fmt.Errorf("updateRepository: %w", err) } - if err = repo_module.SyncReleasesWithTags(repo, gitRepo); err != nil { + // Don't bother looking this repo in the context it won't be there + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return fmt.Errorf("openRepository: %w", err) + } + defer gitRepo.Close() + + if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { return fmt.Errorf("SyncReleasesWithTags: %w", err) } @@ -259,7 +259,7 @@ func checkUnadoptedRepositories(ctx context.Context, userName string, repoNamesT } return err } - repos, _, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ + repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: ctxUser, Private: true, ListOptions: db.ListOptions{ diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index 2e3defee8d1..01c58f0ce40 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -9,13 +9,13 @@ import ( "fmt" "io" "os" - "regexp" "strings" "time" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -36,10 +36,6 @@ type ArchiveRequest struct { CommitID string } -// SHA1 hashes will only go up to 40 characters, but SHA256 hashes will go all -// the way to 64. -var shaRegex = regexp.MustCompile(`^[0-9a-f]{4,64}$`) - // ErrUnknownArchiveFormat request archive format is not supported type ErrUnknownArchiveFormat struct { RequestFormat string @@ -96,30 +92,13 @@ func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest r.refName = strings.TrimSuffix(uri, ext) - var err error // Get corresponding commit. - if repo.IsBranchExist(r.refName) { - r.CommitID, err = repo.GetBranchCommitID(r.refName) - if err != nil { - return nil, err - } - } else if repo.IsTagExist(r.refName) { - r.CommitID, err = repo.GetTagCommitID(r.refName) - if err != nil { - return nil, err - } - } else if shaRegex.MatchString(r.refName) { - if repo.IsCommitExist(r.refName) { - r.CommitID = r.refName - } else { - return nil, git.ErrNotExist{ - ID: r.refName, - } - } - } else { + commitID, err := repo.ConvertToGitID(r.refName) + if err != nil { return nil, RepoRefNotFoundError{RefName: r.refName} } + r.CommitID = commitID.String() return r, nil } @@ -172,8 +151,8 @@ func (aReq *ArchiveRequest) Await(ctx context.Context) (*repo_model.RepoArchiver } } -func doArchive(r *ArchiveRequest) (*repo_model.RepoArchiver, error) { - txCtx, committer, err := db.TxContext(db.DefaultContext) +func doArchive(ctx context.Context, r *ArchiveRequest) (*repo_model.RepoArchiver, error) { + txCtx, committer, err := db.TxContext(ctx) if err != nil { return nil, err } @@ -199,7 +178,7 @@ func doArchive(r *ArchiveRequest) (*repo_model.RepoArchiver, error) { CommitID: r.CommitID, Status: repo_model.ArchiverGenerating, } - if err := repo_model.AddRepoArchiver(ctx, archiver); err != nil { + if err := db.Insert(ctx, archiver); err != nil { return nil, err } } @@ -231,7 +210,7 @@ func doArchive(r *ArchiveRequest) (*repo_model.RepoArchiver, error) { return nil, fmt.Errorf("archiver.LoadRepo failed: %w", err) } - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { return nil, err } @@ -291,18 +270,18 @@ func doArchive(r *ArchiveRequest) (*repo_model.RepoArchiver, error) { // anything. In all cases, the caller should be examining the *ArchiveRequest // being returned for completion, as it may be different than the one they passed // in. -func ArchiveRepository(request *ArchiveRequest) (*repo_model.RepoArchiver, error) { - return doArchive(request) +func ArchiveRepository(ctx context.Context, request *ArchiveRequest) (*repo_model.RepoArchiver, error) { + return doArchive(ctx, request) } var archiverQueue *queue.WorkerPoolQueue[*ArchiveRequest] // Init initializes archiver -func Init() error { +func Init(ctx context.Context) error { handler := func(items ...*ArchiveRequest) []*ArchiveRequest { for _, archiveReq := range items { log.Trace("ArchiverData Process: %#v", archiveReq) - if _, err := doArchive(archiveReq); err != nil { + if _, err := doArchive(ctx, archiveReq); err != nil { log.Error("Archive %v failed: %v", archiveReq, err) } } @@ -331,7 +310,7 @@ func StartArchive(request *ArchiveRequest) error { } func deleteOldRepoArchiver(ctx context.Context, archiver *repo_model.RepoArchiver) error { - if err := repo_model.DeleteRepoArchiver(ctx, archiver); err != nil { + if _, err := db.DeleteByID[repo_model.RepoArchiver](ctx, archiver.ID); err != nil { return err } p := archiver.RelativePath() @@ -346,7 +325,7 @@ func DeleteOldRepositoryArchives(ctx context.Context, olderThan time.Duration) e log.Trace("Doing: ArchiveCleanup") for { - archivers, err := repo_model.FindRepoArchives(repo_model.FindRepoArchiversOption{ + archivers, err := db.Find[repo_model.RepoArchiver](ctx, repo_model.FindRepoArchiversOption{ ListOptions: db.ListOptions{ PageSize: 100, Page: 1, @@ -374,7 +353,7 @@ func DeleteOldRepositoryArchives(ctx context.Context, olderThan time.Duration) e // DeleteRepositoryArchives deletes all repositories' archives. func DeleteRepositoryArchives(ctx context.Context) error { - if err := repo_model.DeleteAllRepoArchives(); err != nil { + if err := repo_model.DeleteAllRepoArchives(ctx); err != nil { return err } return storage.Clean(storage.RepoArchives) diff --git a/services/repository/archiver/archiver_test.go b/services/repository/archiver/archiver_test.go index a1bc355d4e9..ec6e9dfac3c 100644 --- a/services/repository/archiver/archiver_test.go +++ b/services/repository/archiver/archiver_test.go @@ -5,12 +5,12 @@ package archiver import ( "errors" - "path/filepath" "testing" "time" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" + "code.gitea.io/gitea/services/contexttest" _ "code.gitea.io/gitea/models/actions" @@ -18,9 +18,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } func TestArchive_Basic(t *testing.T) { @@ -82,13 +80,13 @@ func TestArchive_Basic(t *testing.T) { inFlight[1] = tgzReq inFlight[2] = secondReq - ArchiveRepository(zipReq) - ArchiveRepository(tgzReq) - ArchiveRepository(secondReq) + ArchiveRepository(db.DefaultContext, zipReq) + ArchiveRepository(db.DefaultContext, tgzReq) + ArchiveRepository(db.DefaultContext, secondReq) // Make sure sending an unprocessed request through doesn't affect the queue // count. - ArchiveRepository(zipReq) + ArchiveRepository(db.DefaultContext, zipReq) // Sleep two seconds to make sure the queue doesn't change. time.Sleep(2 * time.Second) @@ -103,7 +101,7 @@ func TestArchive_Basic(t *testing.T) { // We still have the other three stalled at completion, waiting to remove // from archiveInProgress. Try to submit this new one before its // predecessor has cleared out of the queue. - ArchiveRepository(zipReq2) + ArchiveRepository(db.DefaultContext, zipReq2) // Now we'll submit a request and TimedWaitForCompletion twice, before and // after we release it. We should trigger both the timeout and non-timeout @@ -111,7 +109,7 @@ func TestArchive_Basic(t *testing.T) { timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz") assert.NoError(t, err) assert.NotNil(t, timedReq) - ArchiveRepository(timedReq) + ArchiveRepository(db.DefaultContext, timedReq) zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") assert.NoError(t, err) diff --git a/services/repository/branch.go b/services/repository/branch.go index 620e0b6c9f6..229ac54f307 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -10,17 +10,23 @@ import ( "strings" "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/queue" repo_module "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/timeutil" + webhook_module "code.gitea.io/gitea/modules/webhook" notify_service "code.gitea.io/gitea/services/notify" files_service "code.gitea.io/gitea/services/repository/files" @@ -28,30 +34,13 @@ import ( ) // CreateNewBranch creates a new repository branch -func CreateNewBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldBranchName, branchName string) (err error) { - // Check if branch name can be used - if err := checkBranchName(ctx, repo, branchName); err != nil { +func CreateNewBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, oldBranchName, branchName string) (err error) { + branch, err := git_model.GetBranch(ctx, repo.ID, oldBranchName) + if err != nil { return err } - if !git.IsBranchExist(ctx, repo.RepoPath(), oldBranchName) { - return git_model.ErrBranchNotExist{ - BranchName: oldBranchName, - } - } - - if err := git.Push(ctx, repo.RepoPath(), git.PushOptions{ - Remote: repo.RepoPath(), - Branch: fmt.Sprintf("%s%s:%s%s", git.BranchPrefix, oldBranchName, git.BranchPrefix, branchName), - Env: repo_module.PushingEnvironment(doer, repo), - }); err != nil { - if git.IsErrPushOutOfDate(err) || git.IsErrPushRejected(err) { - return err - } - return fmt.Errorf("push: %w", err) - } - - return nil + return CreateNewBranchFromCommit(ctx, doer, repo, gitRepo, branch.CommitID, branchName) } // Branch contains the branch information @@ -66,7 +55,7 @@ type Branch struct { } // LoadBranches loads branches from the repository limited by page & pageSize. -func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch util.OptionalBool, page, pageSize int) (*Branch, []*Branch, int64, error) { +func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, isDeletedBranch optional.Option[bool], keyword string, page, pageSize int) (*Branch, []*Branch, int64, error) { defaultDBBranch, err := git_model.GetBranch(ctx, repo.ID, repo.DefaultBranch) if err != nil { return nil, nil, 0, err @@ -79,24 +68,19 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git Page: page, PageSize: pageSize, }, + Keyword: keyword, + ExcludeBranchNames: []string{repo.DefaultBranch}, } - totalNumOfBranches, err := git_model.CountBranches(ctx, branchOpts) - if err != nil { - return nil, nil, 0, err - } - - branchOpts.ExcludeBranchNames = []string{repo.DefaultBranch} - - dbBranches, err := git_model.FindBranches(ctx, branchOpts) + dbBranches, totalNumOfBranches, err := db.FindAndCount[git_model.Branch](ctx, branchOpts) if err != nil { return nil, nil, 0, err } - if err := dbBranches.LoadDeletedBy(ctx); err != nil { + if err := git_model.BranchList(dbBranches).LoadDeletedBy(ctx); err != nil { return nil, nil, 0, err } - if err := dbBranches.LoadPusher(ctx); err != nil { + if err := git_model.BranchList(dbBranches).LoadPusher(ctx); err != nil { return nil, nil, 0, err } @@ -117,7 +101,6 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git if err != nil { return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err) } - branches = append(branches, branch) } @@ -127,10 +110,44 @@ func LoadBranches(ctx context.Context, repo *repo_model.Repository, gitRepo *git if err != nil { return nil, nil, 0, fmt.Errorf("loadOneBranch: %v", err) } - return defaultBranch, branches, totalNumOfBranches, nil } +func getDivergenceCacheKey(repoID int64, branchName string) string { + return fmt.Sprintf("%d-%s", repoID, branchName) +} + +// getDivergenceFromCache gets the divergence from cache +func getDivergenceFromCache(repoID int64, branchName string) (*git.DivergeObject, bool) { + data := cache.GetCache().Get(getDivergenceCacheKey(repoID, branchName)) + res := git.DivergeObject{ + Ahead: -1, + Behind: -1, + } + s, ok := data.([]byte) + if !ok || len(s) == 0 { + return &res, false + } + + if err := json.Unmarshal(s, &res); err != nil { + log.Error("json.UnMarshal failed: %v", err) + return &res, false + } + return &res, true +} + +func putDivergenceFromCache(repoID int64, branchName string, divergence *git.DivergeObject) error { + bs, err := json.Marshal(divergence) + if err != nil { + return err + } + return cache.GetCache().Put(getDivergenceCacheKey(repoID, branchName), bs, 30*24*60*60) +} + +func DelDivergenceFromCache(repoID int64, branchName string) error { + return cache.GetCache().Delete(getDivergenceCacheKey(repoID, branchName)) +} + func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *git_model.Branch, protectedBranches *git_model.ProtectedBranchRules, repoIDToRepo map[int64]*repo_model.Repository, repoIDToGitRepo map[int64]*git.Repository, @@ -141,21 +158,31 @@ func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *g p := protectedBranches.GetFirstMatched(branchName) isProtected := p != nil - divergence := &git.DivergeObject{ - Ahead: -1, - Behind: -1, - } + var divergence *git.DivergeObject // it's not default branch if repo.DefaultBranch != dbBranch.Name && !dbBranch.IsDeleted { - var err error - divergence, err = files_service.CountDivergingCommits(ctx, repo, git.BranchPrefix+branchName) - if err != nil { - log.Error("CountDivergingCommits: %v", err) + var cached bool + divergence, cached = getDivergenceFromCache(repo.ID, dbBranch.Name) + if !cached { + var err error + divergence, err = files_service.CountDivergingCommits(ctx, repo, git.BranchPrefix+branchName) + if err != nil { + log.Error("CountDivergingCommits: %v", err) + } else { + if err = putDivergenceFromCache(repo.ID, dbBranch.Name, divergence); err != nil { + log.Error("putDivergenceFromCache: %v", err) + } + } } } - pr, err := issues_model.GetLatestPullRequestByHeadInfo(repo.ID, branchName) + if divergence == nil { + // tolerate the error that we cannot get divergence + divergence = &git.DivergeObject{Ahead: -1, Behind: -1} + } + + pr, err := issues_model.GetLatestPullRequestByHeadInfo(ctx, repo.ID, branchName) if err != nil { return nil, fmt.Errorf("GetLatestPullRequestByHeadInfo: %v", err) } @@ -179,7 +206,7 @@ func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *g if pr.HasMerged { baseGitRepo, ok := repoIDToGitRepo[pr.BaseRepoID] if !ok { - baseGitRepo, err = git.OpenRepository(ctx, pr.BaseRepo.RepoPath()) + baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) if err != nil { return nil, fmt.Errorf("OpenRepository: %v", err) } @@ -209,13 +236,9 @@ func loadOneBranch(ctx context.Context, repo *repo_model.Repository, dbBranch *g }, nil } -func GetBranchCommitID(ctx context.Context, repo *repo_model.Repository, branch string) (string, error) { - return git.GetBranchCommitID(ctx, repo.RepoPath(), branch) -} - // checkBranchName validates branch name with existing repository branches func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error { - _, err := git.WalkReferences(ctx, repo.RepoPath(), func(_, refName string) error { + _, err := gitrepo.WalkReferences(ctx, repo, func(_, refName string) error { branchRefName := strings.TrimPrefix(refName, git.BranchPrefix) switch { case branchRefName == name: @@ -243,8 +266,101 @@ func checkBranchName(ctx context.Context, repo *repo_model.Repository, name stri return err } +// SyncBranchesToDB sync the branch information in the database. +// It will check whether the branches of the repository have never been synced before. +// If so, it will sync all branches of the repository. +// Otherwise, it will sync the branches that need to be updated. +func SyncBranchesToDB(ctx context.Context, repoID, pusherID int64, branchNames, commitIDs []string, getCommit func(commitID string) (*git.Commit, error)) error { + // Some designs that make the code look strange but are made for performance optimization purposes: + // 1. Sync branches in a batch to reduce the number of DB queries. + // 2. Lazy load commit information since it may be not necessary. + // 3. Exit early if synced all branches of git repo when there's no branch in DB. + // 4. Check the branches in DB if they are already synced. + // + // If the user pushes many branches at once, the Git hook will call the internal API in batches, rather than all at once. + // See https://github.com/go-gitea/gitea/blob/cb52b17f92e2d2293f7c003649743464492bca48/cmd/hook.go#L27 + // For the first batch, it will hit optimization 3. + // For other batches, it will hit optimization 4. + + if len(branchNames) != len(commitIDs) { + return fmt.Errorf("branchNames and commitIDs length not match") + } + + return db.WithTx(ctx, func(ctx context.Context) error { + branches, err := git_model.GetBranches(ctx, repoID, branchNames) + if err != nil { + return fmt.Errorf("git_model.GetBranches: %v", err) + } + + if len(branches) == 0 { + // if user haven't visit UI but directly push to a branch after upgrading from 1.20 -> 1.21, + // we cannot simply insert the branch but need to check we have branches or not + hasBranch, err := db.Exist[git_model.Branch](ctx, git_model.FindBranchOptions{ + RepoID: repoID, + IsDeletedBranch: optional.Some(false), + }.ToConds()) + if err != nil { + return err + } + if !hasBranch { + if _, err = repo_module.SyncRepoBranches(ctx, repoID, pusherID); err != nil { + return fmt.Errorf("repo_module.SyncRepoBranches %d failed: %v", repoID, err) + } + return nil + } + } + + branchMap := make(map[string]*git_model.Branch, len(branches)) + for _, branch := range branches { + branchMap[branch.Name] = branch + } + + newBranches := make([]*git_model.Branch, 0, len(branchNames)) + + for i, branchName := range branchNames { + commitID := commitIDs[i] + branch, exist := branchMap[branchName] + if exist && branch.CommitID == commitID && !branch.IsDeleted { + continue + } + + commit, err := getCommit(commitID) + if err != nil { + return fmt.Errorf("get commit of %s failed: %v", branchName, err) + } + + if exist { + if _, err := git_model.UpdateBranch(ctx, repoID, pusherID, branchName, commit); err != nil { + return fmt.Errorf("git_model.UpdateBranch %d:%s failed: %v", repoID, branchName, err) + } + return nil + } + + // if database have branches but not this branch, it means this is a new branch + newBranches = append(newBranches, &git_model.Branch{ + RepoID: repoID, + Name: branchName, + CommitID: commit.ID.String(), + CommitMessage: commit.Summary(), + PusherID: pusherID, + CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()), + }) + } + + if len(newBranches) > 0 { + return db.Insert(ctx, newBranches) + } + return nil + }) +} + // CreateNewBranchFromCommit creates a new repository branch -func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commit, branchName string) (err error) { +func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, commitID, branchName string) (err error) { + err = repo.MustNotBeArchived() + if err != nil { + return err + } + // Check if branch name can be used if err := checkBranchName(ctx, repo, branchName); err != nil { return err @@ -252,7 +368,7 @@ func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo if err := git.Push(ctx, repo.RepoPath(), git.PushOptions{ Remote: repo.RepoPath(), - Branch: fmt.Sprintf("%s:%s%s", commit, git.BranchPrefix, branchName), + Branch: fmt.Sprintf("%s:%s%s", commitID, git.BranchPrefix, branchName), Env: repo_module.PushingEnvironment(doer, repo), }); err != nil { if git.IsErrPushOutOfDate(err) || git.IsErrPushRejected(err) { @@ -260,12 +376,16 @@ func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo } return fmt.Errorf("push: %w", err) } - return nil } // RenameBranch rename a branch func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, gitRepo *git.Repository, from, to string) (string, error) { + err := repo.MustNotBeArchived() + if err != nil { + return "", err + } + if from == to { return "target_exist", nil } @@ -278,14 +398,29 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m return "from_not_exist", nil } - if err := git_model.RenameBranch(ctx, repo, from, to, func(isDefault bool) error { + if err := git_model.RenameBranch(ctx, repo, from, to, func(ctx context.Context, isDefault bool) error { err2 := gitRepo.RenameBranch(from, to) if err2 != nil { return err2 } if isDefault { - err2 = gitRepo.SetDefaultBranch(to) + // if default branch changed, we need to delete all schedules and cron jobs + if err := actions_model.DeleteScheduleTaskByRepo(ctx, repo.ID); err != nil { + log.Error("DeleteCronTaskByRepo: %v", err) + } + // cancel running cron jobs of this repository and delete old schedules + if err := actions_model.CancelPreviousJobs( + ctx, + repo.ID, + from, + "", + webhook_module.HookEventSchedule, + ); err != nil { + log.Error("CancelPreviousJobs: %v", err) + } + + err2 = gitrepo.SetDefaultBranch(ctx, repo, to) if err2 != nil { return err2 } @@ -314,6 +449,11 @@ var ( // DeleteBranch delete branch func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branchName string) error { + err := repo.MustNotBeArchived() + if err != nil { + return err + } + if branchName == repo.DefaultBranch { return ErrBranchIsDefault } @@ -352,12 +492,14 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R return err } + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) + // Don't return error below this if err := PushUpdate( &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromBranch(branchName), OldCommitID: commit.ID.String(), - NewCommitID: git.EmptySHA, + NewCommitID: objectFormat.EmptyObjectID().String(), PusherID: doer.ID, PusherName: doer.Name, RepoUserName: repo.OwnerName, @@ -410,3 +552,50 @@ func AddAllRepoBranchesToSyncQueue(ctx context.Context, doerID int64) error { } return nil } + +func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, newBranchName string) error { + if repo.DefaultBranch == newBranchName { + return nil + } + + if !gitRepo.IsBranchExist(newBranchName) { + return git_model.ErrBranchNotExist{ + BranchName: newBranchName, + } + } + + oldDefaultBranchName := repo.DefaultBranch + repo.DefaultBranch = newBranchName + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := repo_model.UpdateDefaultBranch(ctx, repo); err != nil { + return err + } + + if err := actions_model.DeleteScheduleTaskByRepo(ctx, repo.ID); err != nil { + log.Error("DeleteCronTaskByRepo: %v", err) + } + // cancel running cron jobs of this repository and delete old schedules + if err := actions_model.CancelPreviousJobs( + ctx, + repo.ID, + oldDefaultBranchName, + "", + webhook_module.HookEventSchedule, + ); err != nil { + log.Error("CancelPreviousJobs: %v", err) + } + + if err := gitrepo.SetDefaultBranch(ctx, repo, newBranchName); err != nil { + if !git.IsErrUnsupportedVersion(err) { + return err + } + } + return nil + }); err != nil { + return err + } + + notify_service.ChangeDefaultBranch(ctx, repo) + + return nil +} diff --git a/services/repository/cache.go b/services/repository/cache.go index 91351cbf491..b0811a99fc0 100644 --- a/services/repository/cache.go +++ b/services/repository/cache.go @@ -9,15 +9,10 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" ) // CacheRef cachhe last commit information of the branch or the tag func CacheRef(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, fullRefName git.RefName) error { - if !setting.CacheService.LastCommit.Enabled { - return nil - } - commit, err := gitRepo.GetCommit(fullRefName.String()) if err != nil { return err diff --git a/services/repository/check.go b/services/repository/check.go index 6ad644561ee..5cdcc146797 100644 --- a/services/repository/check.go +++ b/services/repository/check.go @@ -91,7 +91,6 @@ func GitGcRepo(ctx context.Context, repo *repo_model.Repository, timeout time.Du var stdout string var err error stdout, _, err = command.RunStdString(&git.RunOpts{Timeout: timeout, Dir: repo.RepoPath()}) - if err != nil { log.Error("Repository garbage collection failed for %-v. Stdout: %s\nError: %v", repo, stdout, err) desc := fmt.Sprintf("Repository garbage collection failed for %s. Stdout: %s\nError: %v", repo.RepoPath(), stdout, err) @@ -164,7 +163,7 @@ func DeleteMissingRepositories(ctx context.Context, doer *user_model.User) error default: } log.Trace("Deleting %d/%d...", repo.OwnerID, repo.ID) - if err := DeleteRepositoryDirectly(ctx, doer, repo.OwnerID, repo.ID); err != nil { + if err := DeleteRepositoryDirectly(ctx, doer, repo.ID); err != nil { log.Error("Failed to DeleteRepository %-v: Error: %v", repo, err) if err2 := system_model.CreateRepositoryNotice("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err); err2 != nil { log.Error("CreateRepositoryNotice: %v", err) @@ -192,7 +191,7 @@ func ReinitMissingRepositories(ctx context.Context) error { default: } log.Trace("Initializing %d/%d...", repo.OwnerID, repo.ID) - if err := git.InitRepository(ctx, repo.RepoPath(), true); err != nil { + if err := git.InitRepository(ctx, repo.RepoPath(), true, repo.ObjectFormatName); err != nil { log.Error("Unable (re)initialize repository %d at %s. Error: %v", repo.ID, repo.RepoPath(), err) if err2 := system_model.CreateRepositoryNotice("InitRepository [%d]: %v", repo.ID, err); err2 != nil { log.Error("CreateRepositoryNotice: %v", err2) diff --git a/services/repository/collaboration.go b/services/repository/collaboration.go index 28824d83f5f..4a43ae2a28f 100644 --- a/services/repository/collaboration.go +++ b/services/repository/collaboration.go @@ -5,41 +5,52 @@ package repository import ( + "context" + "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" ) // DeleteCollaboration removes collaboration relation between the user and repository. -func DeleteCollaboration(repo *repo_model.Repository, uid int64) (err error) { +func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) { collaboration := &repo_model.Collaboration{ RepoID: repo.ID, - UserID: uid, + UserID: collaborator.ID, } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if has, err := db.GetEngine(ctx).Delete(collaboration); err != nil || has == 0 { + if has, err := db.GetEngine(ctx).Delete(collaboration); err != nil { + return err + } else if has == 0 { + return committer.Commit() + } + + if err := repo.LoadOwner(ctx); err != nil { return err - } else if err = access_model.RecalculateAccesses(ctx, repo); err != nil { + } + + if err = access_model.RecalculateAccesses(ctx, repo); err != nil { return err } - if err = repo_model.WatchRepo(ctx, uid, repo.ID, false); err != nil { + if err = repo_model.WatchRepo(ctx, collaborator, repo, false); err != nil { return err } - if err = models.ReconsiderWatches(ctx, repo, uid); err != nil { + if err = models.ReconsiderWatches(ctx, repo, collaborator); err != nil { return err } // Unassign a user from any issue (s)he has been assigned to in the repository - if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, uid); err != nil { + if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil { return err } diff --git a/services/repository/collaboration_test.go b/services/repository/collaboration_test.go index 08159af7bcf..a2eb06b81a2 100644 --- a/services/repository/collaboration_test.go +++ b/services/repository/collaboration_test.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -16,13 +17,15 @@ import ( func TestRepository_DeleteCollaboration(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) - assert.NoError(t, DeleteCollaboration(repo, 4)) - unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4}) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) - assert.NoError(t, DeleteCollaboration(repo, 4)) - unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4}) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) } diff --git a/services/repository/commit.go b/services/repository/commit.go index 2497910a838..e8c0262ef41 100644 --- a/services/repository/commit.go +++ b/services/repository/commit.go @@ -7,8 +7,8 @@ import ( "context" "fmt" - gitea_ctx "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/util" + gitea_ctx "code.gitea.io/gitea/services/context" ) type ContainedLinks struct { // TODO: better name? diff --git a/services/repository/commitstatus/commitstatus.go b/services/repository/commitstatus/commitstatus.go new file mode 100644 index 00000000000..145fc7d53c4 --- /dev/null +++ b/services/repository/commitstatus/commitstatus.go @@ -0,0 +1,135 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package commitstatus + +import ( + "context" + "crypto/sha256" + "fmt" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/automerge" +) + +func getCacheKey(repoID int64, brancheName string) string { + hashBytes := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", repoID, brancheName))) + return fmt.Sprintf("commit_status:%x", hashBytes) +} + +func updateCommitStatusCache(ctx context.Context, repoID int64, branchName string, status api.CommitStatusState) error { + c := cache.GetCache() + return c.Put(getCacheKey(repoID, branchName), string(status), 3*24*60) +} + +func deleteCommitStatusCache(ctx context.Context, repoID int64, branchName string) error { + c := cache.GetCache() + return c.Delete(getCacheKey(repoID, branchName)) +} + +// CreateCommitStatus creates a new CommitStatus given a bunch of parameters +// NOTE: All text-values will be trimmed from whitespaces. +// Requires: Repo, Creator, SHA +func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, creator *user_model.User, sha string, status *git_model.CommitStatus) error { + repoPath := repo.RepoPath() + + // confirm that commit is exist + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) + if err != nil { + return fmt.Errorf("OpenRepository[%s]: %w", repoPath, err) + } + defer closer.Close() + + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) + + commit, err := gitRepo.GetCommit(sha) + if err != nil { + return fmt.Errorf("GetCommit[%s]: %w", sha, err) + } + if len(sha) != objectFormat.FullLength() { + // use complete commit sha + sha = commit.ID.String() + } + + if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{ + Repo: repo, + Creator: creator, + SHA: commit.ID, + CommitStatus: status, + }); err != nil { + return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) + } + + defaultBranchCommit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + if err != nil { + return fmt.Errorf("GetBranchCommit[%s]: %w", repo.DefaultBranch, err) + } + + if commit.ID.String() == defaultBranchCommit.ID.String() { // since one commit status updated, the combined commit status should be invalid + if err := deleteCommitStatusCache(ctx, repo.ID, repo.DefaultBranch); err != nil { + log.Error("deleteCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err) + } + } + + if status.State.IsSuccess() { + if err := automerge.MergeScheduledPullRequest(ctx, sha, repo); err != nil { + return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) + } + } + + return nil +} + +// FindReposLastestCommitStatuses loading repository default branch latest combinded commit status with cache +func FindReposLastestCommitStatuses(ctx context.Context, repos []*repo_model.Repository) ([]*git_model.CommitStatus, error) { + results := make([]*git_model.CommitStatus, len(repos)) + c := cache.GetCache() + + for i, repo := range repos { + status, ok := c.Get(getCacheKey(repo.ID, repo.DefaultBranch)).(string) + if ok && status != "" { + results[i] = &git_model.CommitStatus{State: api.CommitStatusState(status)} + } + } + + // collect the latest commit of each repo + // at most there are dozens of repos (limited by MaxResponseItems), so it's not a big problem at the moment + repoBranchNames := make(map[int64]string, len(repos)) + for i, repo := range repos { + if results[i] == nil { + repoBranchNames[repo.ID] = repo.DefaultBranch + } + } + + repoIDsToLatestCommitSHAs, err := git_model.FindBranchesByRepoAndBranchName(ctx, repoBranchNames) + if err != nil { + return nil, fmt.Errorf("FindBranchesByRepoAndBranchName: %v", err) + } + + // call the database O(1) times to get the commit statuses for all repos + repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoIDsToLatestCommitSHAs, db.ListOptionsAll) + if err != nil { + return nil, fmt.Errorf("GetLatestCommitStatusForPairs: %v", err) + } + + for i, repo := range repos { + if results[i] == nil { + results[i] = git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID]) + if results[i].State != "" { + if err := updateCommitStatusCache(ctx, repo.ID, repo.DefaultBranch, results[i].State); err != nil { + log.Error("updateCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err) + } + } + } + } + + return results, nil +} diff --git a/services/repository/contributors_graph.go b/services/repository/contributors_graph.go new file mode 100644 index 00000000000..7c9f535ae01 --- /dev/null +++ b/services/repository/contributors_graph.go @@ -0,0 +1,317 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/models/avatars" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + + "gitea.com/go-chi/cache" +) + +const ( + contributorStatsCacheKey = "GetContributorStats/%s/%s" + contributorStatsCacheTimeout int64 = 60 * 10 +) + +var ( + ErrAwaitGeneration = errors.New("generation took longer than ") + awaitGenerationTime = time.Second * 5 + generateLock = sync.Map{} +) + +type WeekData struct { + Week int64 `json:"week"` // Starting day of the week as Unix timestamp + Additions int `json:"additions"` // Number of additions in that week + Deletions int `json:"deletions"` // Number of deletions in that week + Commits int `json:"commits"` // Number of commits in that week +} + +// ContributorData represents statistical git commit count data +type ContributorData struct { + Name string `json:"name"` // Display name of the contributor + Login string `json:"login"` // Login name of the contributor in case it exists + AvatarLink string `json:"avatar_link"` + HomeLink string `json:"home_link"` + TotalCommits int64 `json:"total_commits"` + Weeks map[int64]*WeekData `json:"weeks"` +} + +// ExtendedCommitStats contains information for commit stats with author data +type ExtendedCommitStats struct { + Author *api.CommitUser `json:"author"` + Stats *api.CommitStats `json:"stats"` +} + +const layout = time.DateOnly + +func findLastSundayBeforeDate(dateStr string) (string, error) { + date, err := time.Parse(layout, dateStr) + if err != nil { + return "", err + } + + weekday := date.Weekday() + daysToSubtract := int(weekday) - int(time.Sunday) + if daysToSubtract < 0 { + daysToSubtract += 7 + } + + lastSunday := date.AddDate(0, 0, -daysToSubtract) + return lastSunday.Format(layout), nil +} + +// GetContributorStats returns contributors stats for git commits for given revision or default branch +func GetContributorStats(ctx context.Context, cache cache.Cache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) { + // as GetContributorStats is resource intensive we cache the result + cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision) + if !cache.IsExist(cacheKey) { + genReady := make(chan struct{}) + + // dont start multible async generations + _, run := generateLock.Load(cacheKey) + if run { + return nil, ErrAwaitGeneration + } + + generateLock.Store(cacheKey, struct{}{}) + // run generation async + go generateContributorStats(genReady, cache, cacheKey, repo, revision) + + select { + case <-time.After(awaitGenerationTime): + return nil, ErrAwaitGeneration + case <-genReady: + // we got generation ready before timeout + break + } + } + // TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout) + + switch v := cache.Get(cacheKey).(type) { + case error: + return nil, v + case map[string]*ContributorData: + return v, nil + default: + return nil, fmt.Errorf("unexpected type in cache detected") + } +} + +// getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision +func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) { + baseCommit, err := repo.GetCommit(revision) + if err != nil { + return nil, err + } + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, err + } + defer func() { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + }() + + gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse") + // AddOptionFormat("--max-count=%d", limit) + gitCmd.AddDynamicArguments(baseCommit.ID.String()) + + var extendedCommitStats []*ExtendedCommitStats + stderr := new(strings.Builder) + err = gitCmd.Run(&git.RunOpts{ + Dir: repo.Path, + Stdout: stdoutWriter, + Stderr: stderr, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + scanner := bufio.NewScanner(stdoutReader) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "---" { + continue + } + scanner.Scan() + authorName := strings.TrimSpace(scanner.Text()) + scanner.Scan() + authorEmail := strings.TrimSpace(scanner.Text()) + scanner.Scan() + date := strings.TrimSpace(scanner.Text()) + scanner.Scan() + stats := strings.TrimSpace(scanner.Text()) + if authorName == "" || authorEmail == "" || date == "" || stats == "" { + // FIXME: find a better way to parse the output so that we will handle this properly + log.Warn("Something is wrong with git log output, skipping...") + log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats) + continue + } + // 1 file changed, 1 insertion(+), 1 deletion(-) + fields := strings.Split(stats, ",") + + commitStats := api.CommitStats{} + for _, field := range fields[1:] { + parts := strings.Split(strings.TrimSpace(field), " ") + value, contributionType := parts[0], parts[1] + amount, _ := strconv.Atoi(value) + + if strings.HasPrefix(contributionType, "insertion") { + commitStats.Additions = amount + } else { + commitStats.Deletions = amount + } + } + commitStats.Total = commitStats.Additions + commitStats.Deletions + scanner.Text() // empty line at the end + + res := &ExtendedCommitStats{ + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: authorName, + Email: authorEmail, + }, + Date: date, + }, + Stats: &commitStats, + } + extendedCommitStats = append(extendedCommitStats, res) + + } + _ = stdoutReader.Close() + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr) + } + + return extendedCommitStats, nil +} + +func generateContributorStats(genDone chan struct{}, cache cache.Cache, cacheKey string, repo *repo_model.Repository, revision string) { + ctx := graceful.GetManager().HammerContext() + + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) + if err != nil { + err := fmt.Errorf("OpenRepository: %w", err) + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) + return + } + defer closer.Close() + + if len(revision) == 0 { + revision = repo.DefaultBranch + } + extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision) + if err != nil { + err := fmt.Errorf("ExtendedCommitStats: %w", err) + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) + return + } + if len(extendedCommitStats) == 0 { + err := fmt.Errorf("no commit stats returned for revision '%s'", revision) + _ = cache.Put(cacheKey, err, contributorStatsCacheTimeout) + return + } + + layout := time.DateOnly + + unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0) + contributorsCommitStats := make(map[string]*ContributorData) + contributorsCommitStats["total"] = &ContributorData{ + Name: "Total", + Weeks: make(map[int64]*WeekData), + } + total := contributorsCommitStats["total"] + + for _, v := range extendedCommitStats { + userEmail := v.Author.Email + if len(userEmail) == 0 { + continue + } + u, _ := user_model.GetUserByEmail(ctx, userEmail) + if u != nil { + // update userEmail with user's primary email address so + // that different mail addresses will linked to same account + userEmail = u.GetEmail() + } + // duplicated logic + if _, ok := contributorsCommitStats[userEmail]; !ok { + if u == nil { + avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0) + if avatarLink == "" { + avatarLink = unknownUserAvatarLink + } + contributorsCommitStats[userEmail] = &ContributorData{ + Name: v.Author.Name, + AvatarLink: avatarLink, + Weeks: make(map[int64]*WeekData), + } + } else { + contributorsCommitStats[userEmail] = &ContributorData{ + Name: u.DisplayName(), + Login: u.LowerName, + AvatarLink: u.AvatarLinkWithSize(ctx, 0), + HomeLink: u.HomeLink(), + Weeks: make(map[int64]*WeekData), + } + } + } + // Update user statistics + user := contributorsCommitStats[userEmail] + startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date) + + val, _ := time.Parse(layout, startingOfWeek) + week := val.UnixMilli() + + if user.Weeks[week] == nil { + user.Weeks[week] = &WeekData{ + Additions: 0, + Deletions: 0, + Commits: 0, + Week: week, + } + } + if total.Weeks[week] == nil { + total.Weeks[week] = &WeekData{ + Additions: 0, + Deletions: 0, + Commits: 0, + Week: week, + } + } + user.Weeks[week].Additions += v.Stats.Additions + user.Weeks[week].Deletions += v.Stats.Deletions + user.Weeks[week].Commits++ + user.TotalCommits++ + + // Update overall statistics + total.Weeks[week].Additions += v.Stats.Additions + total.Weeks[week].Deletions += v.Stats.Deletions + total.Weeks[week].Commits++ + total.TotalCommits++ + } + + _ = cache.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout) + generateLock.Delete(cacheKey) + if genDone != nil { + genDone <- struct{}{} + } +} diff --git a/services/repository/contributors_graph_test.go b/services/repository/contributors_graph_test.go new file mode 100644 index 00000000000..3801a5eee4b --- /dev/null +++ b/services/repository/contributors_graph_test.go @@ -0,0 +1,87 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "slices" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + + "gitea.com/go-chi/cache" + "github.com/stretchr/testify/assert" +) + +func TestRepository_ContributorsGraph(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) + mockCache, err := cache.NewCacher(cache.Options{ + Adapter: "memory", + Interval: 24 * 60, + }) + assert.NoError(t, err) + + generateContributorStats(nil, mockCache, "key", repo, "404ref") + err, isErr := mockCache.Get("key").(error) + assert.True(t, isErr) + assert.ErrorAs(t, err, &git.ErrNotExist{}) + + generateContributorStats(nil, mockCache, "key2", repo, "master") + data, isData := mockCache.Get("key2").(map[string]*ContributorData) + assert.True(t, isData) + var keys []string + for k := range data { + keys = append(keys, k) + } + slices.Sort(keys) + assert.EqualValues(t, []string{ + "ethantkoenig@gmail.com", + "jimmy.praet@telenet.be", + "jon@allspice.io", + "total", // generated summary + }, keys) + + assert.EqualValues(t, &ContributorData{ + Name: "Ethan Koenig", + AvatarLink: "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon", + TotalCommits: 1, + Weeks: map[int64]*WeekData{ + 1511654400000: { + Week: 1511654400000, // sunday 2017-11-26 + Additions: 3, + Deletions: 0, + Commits: 1, + }, + }, + }, data["ethantkoenig@gmail.com"]) + assert.EqualValues(t, &ContributorData{ + Name: "Total", + AvatarLink: "", + TotalCommits: 3, + Weeks: map[int64]*WeekData{ + 1511654400000: { + Week: 1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800) + Additions: 3, + Deletions: 0, + Commits: 1, + }, + 1607817600000: { + Week: 1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500) + Additions: 10, + Deletions: 0, + Commits: 1, + }, + 1624752000000: { + Week: 1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200) + Additions: 2, + Deletions: 0, + Commits: 1, + }, + }, + }, data["total"]) +} diff --git a/services/repository/create.go b/services/repository/create.go index 09956b74d45..971793bcc6e 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -16,6 +16,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/options" repo_module "code.gitea.io/gitea/modules/repository" @@ -27,22 +28,23 @@ import ( // CreateRepoOptions contains the create repository options type CreateRepoOptions struct { - Name string - Description string - OriginalURL string - GitServiceType api.GitServiceType - Gitignores string - IssueLabels string - License string - Readme string - DefaultBranch string - IsPrivate bool - IsMirror bool - IsTemplate bool - AutoInit bool - Status repo_model.RepositoryStatus - TrustModel repo_model.TrustModelType - MirrorInterval string + Name string + Description string + OriginalURL string + GitServiceType api.GitServiceType + Gitignores string + IssueLabels string + License string + Readme string + DefaultBranch string + IsPrivate bool + IsMirror bool + IsTemplate bool + AutoInit bool + Status repo_model.RepositoryStatus + TrustModel repo_model.TrustModelType + MirrorInterval string + ObjectFormatName string } func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, repoPath string, opts CreateRepoOptions) error { @@ -134,7 +136,7 @@ func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, // InitRepository initializes README and .gitignore if needed. func initRepository(ctx context.Context, repoPath string, u *user_model.User, repo *repo_model.Repository, opts CreateRepoOptions) (err error) { - if err = repo_module.CheckInitRepository(ctx, repo.OwnerName, repo.Name); err != nil { + if err = repo_module.CheckInitRepository(ctx, repo.OwnerName, repo.Name, opts.ObjectFormatName); err != nil { return err } @@ -155,7 +157,7 @@ func initRepository(ctx context.Context, repoPath string, u *user_model.User, re } // Apply changes and commit. - if err = repo_module.InitRepoCommit(ctx, tmpDir, repo, u, opts.DefaultBranch); err != nil { + if err = initRepoCommit(ctx, tmpDir, repo, u, opts.DefaultBranch); err != nil { return fmt.Errorf("initRepoCommit: %w", err) } } @@ -171,15 +173,11 @@ func initRepository(ctx context.Context, repoPath string, u *user_model.User, re } repo.DefaultBranch = setting.Repository.DefaultBranch + repo.DefaultWikiBranch = setting.Repository.DefaultBranch if len(opts.DefaultBranch) > 0 { repo.DefaultBranch = opts.DefaultBranch - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) - if err != nil { - return fmt.Errorf("openRepository: %w", err) - } - defer gitRepo.Close() - if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { return fmt.Errorf("setDefaultBranch: %w", err) } @@ -216,6 +214,10 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt } } + if opts.ObjectFormatName == "" { + opts.ObjectFormatName = git.Sha1ObjectFormat.Name() + } + repo := &repo_model.Repository{ OwnerID: u.ID, Owner: u, @@ -234,6 +236,8 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt TrustModel: opts.TrustModel, IsMirror: opts.IsMirror, DefaultBranch: opts.DefaultBranch, + DefaultWikiBranch: setting.Repository.DefaultBranch, + ObjectFormatName: opts.ObjectFormatName, } var rollbackRepo *repo_model.Repository @@ -302,7 +306,7 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt return nil }); err != nil { if rollbackRepo != nil { - if errDelete := DeleteRepositoryDirectly(ctx, doer, rollbackRepo.OwnerID, rollbackRepo.ID); errDelete != nil { + if errDelete := DeleteRepositoryDirectly(ctx, doer, rollbackRepo.ID); errDelete != nil { log.Error("Rollback deleteRepository: %v", errDelete) } } diff --git a/services/repository/create_test.go b/services/repository/create_test.go index 36065cafff8..41e6b615db7 100644 --- a/services/repository/create_test.go +++ b/services/repository/create_test.go @@ -21,12 +21,12 @@ import ( func TestIncludesAllRepositoriesTeams(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testTeamRepositories := func(teamID int64, repoIds []int64) { + testTeamRepositories := func(teamID int64, repoIDs []int64) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name) assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name) - assert.Len(t, team.Repos, len(repoIds), "%s: repo count", team.Name) - for i, rid := range repoIds { + assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name) + for i, rid := range repoIDs { if rid > 0 { assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i) } @@ -44,7 +44,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { Type: user_model.UserTypeOrganization, Visibility: structs.VisibleTypePublic, } - assert.NoError(t, organization.CreateOrganization(org, user), "CreateOrganization") + assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization") // Check Owner team. ownerTeam, err := org.GetOwnerTeam(db.DefaultContext) @@ -52,12 +52,12 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") // Create repos. - repoIds := make([]int64, 0) + repoIDs := make([]int64, 0) for i := 0; i < 3; i++ { r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}) assert.NoError(t, err, "CreateRepository %d", i) if r != nil { - repoIds = append(repoIds, r.ID) + repoIDs = append(repoIDs, r.ID) } } // Get fresh copy of Owner team after creating repos. @@ -93,10 +93,10 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { }, } teamRepos := [][]int64{ - repoIds, - repoIds, + repoIDs, + repoIDs, {}, - repoIds, + repoIDs, {}, } for i, team := range teams { @@ -109,7 +109,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { // Update teams and check repositories. teams[3].IncludesAllRepositories = false teams[4].IncludesAllRepositories = true - teamRepos[4] = repoIds + teamRepos[4] = repoIDs for i, team := range teams { assert.NoError(t, models.UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name) testTeamRepositories(team.ID, teamRepos[i]) @@ -119,29 +119,29 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: "repo-last"}) assert.NoError(t, err, "CreateRepository last") if r != nil { - repoIds = append(repoIds, r.ID) + repoIDs = append(repoIDs, r.ID) } - teamRepos[0] = repoIds - teamRepos[1] = repoIds - teamRepos[4] = repoIds + teamRepos[0] = repoIDs + teamRepos[1] = repoIDs + teamRepos[4] = repoIDs for i, team := range teams { testTeamRepositories(team.ID, teamRepos[i]) } // Remove repo and check teams repositories. - assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, org.ID, repoIds[0]), "DeleteRepository") - teamRepos[0] = repoIds[1:] - teamRepos[1] = repoIds[1:] - teamRepos[3] = repoIds[1:3] - teamRepos[4] = repoIds[1:] + assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository") + teamRepos[0] = repoIDs[1:] + teamRepos[1] = repoIDs[1:] + teamRepos[3] = repoIDs[1:3] + teamRepos[4] = repoIDs[1:] for i, team := range teams { testTeamRepositories(team.ID, teamRepos[i]) } // Wipe created items. - for i, rid := range repoIds { + for i, rid := range repoIDs { if i > 0 { // first repo already deleted. - assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, org.ID, rid), "DeleteRepository %d", i) + assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i) } } assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization") diff --git a/services/repository/delete.go b/services/repository/delete.go index f2b47814585..8d6729f31bc 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -27,13 +27,14 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" + asymkey_service "code.gitea.io/gitea/services/asymkey" "xorm.io/builder" ) // DeleteRepository deletes a repository for a user or organization. // make sure if you call this func to close open sessions (sqlite will otherwise get a deadlock) -func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, uid, repoID int64) error { +func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID int64, ignoreOrgTeams ...bool) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -41,39 +42,41 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, uid, r defer committer.Close() sess := db.GetEngine(ctx) + repo := &repo_model.Repository{} + has, err := sess.ID(repoID).Get(repo) + if err != nil { + return err + } else if !has { + return repo_model.ErrRepoNotExist{ + ID: repoID, + OwnerName: "", + Name: "", + } + } + // Query the action tasks of this repo, they will be needed after they have been deleted to remove the logs - tasks, err := actions_model.FindTasks(ctx, actions_model.FindTaskOptions{RepoID: repoID}) + tasks, err := db.Find[actions_model.ActionTask](ctx, actions_model.FindTaskOptions{RepoID: repoID}) if err != nil { return fmt.Errorf("find actions tasks of repo %v: %w", repoID, err) } // Query the artifacts of this repo, they will be needed after they have been deleted to remove artifacts files in ObjectStorage - artifacts, err := actions_model.ListArtifactsByRepoID(ctx, repoID) + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{RepoID: repoID}) if err != nil { return fmt.Errorf("list actions artifacts of repo %v: %w", repoID, err) } - // In case is a organization. - org, err := user_model.GetUserByID(ctx, uid) - if err != nil { - return err - } - - repo := &repo_model.Repository{OwnerID: uid} - has, err := sess.ID(repoID).Get(repo) - if err != nil { - return err - } else if !has { - return repo_model.ErrRepoNotExist{ - ID: repoID, - UID: uid, - OwnerName: "", - Name: "", + // In case owner is a organization, we have to change repo specific teams + // if ignoreOrgTeams is not true + var org *user_model.User + if len(ignoreOrgTeams) == 0 || !ignoreOrgTeams[0] { + if org, err = user_model.GetUserByID(ctx, repo.OwnerID); err != nil { + return err } } // Delete Deploy Keys - deployKeys, err := asymkey_model.ListDeployKeys(ctx, &asymkey_model.ListDeployKeysOptions{RepoID: repoID}) + deployKeys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID}) if err != nil { return fmt.Errorf("listDeployKeys: %w", err) } @@ -89,13 +92,12 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, uid, r } else if cnt != 1 { return repo_model.ErrRepoNotExist{ ID: repoID, - UID: uid, OwnerName: "", Name: "", } } - if org.IsOrganization() { + if org != nil && org.IsOrganization() { teams, err := organization.FindOrgTeams(ctx, org.ID) if err != nil { return err @@ -192,7 +194,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, uid, r } } - if _, err := db.Exec(ctx, "UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", uid); err != nil { + if _, err := db.Exec(ctx, "UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", repo.OwnerID); err != nil { return err } @@ -276,7 +278,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, uid, r committer.Close() if needRewriteKeysFile { - if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { log.Error("RewriteAllPublicKeys failed: %v", err) } } @@ -364,24 +366,26 @@ func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *r } } - teamUsers, err := organization.GetTeamUsersByTeamID(ctx, t.ID) + teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + TeamID: t.ID, + }) if err != nil { - return fmt.Errorf("getTeamUsersByTeamID: %w", err) + return fmt.Errorf("GetTeamMembers: %w", err) } - for _, teamUser := range teamUsers { - has, err := access_model.HasAccess(ctx, teamUser.UID, repo) + for _, member := range teamMembers { + has, err := access_model.HasAccess(ctx, member.ID, repo) if err != nil { return err } else if has { continue } - if err = repo_model.WatchRepo(ctx, teamUser.UID, repo.ID, false); err != nil { + if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil { return err } // Remove all IssueWatches a user has subscribed to in the repositories - if err := issues_model.RemoveIssueWatchersByRepoID(ctx, teamUser.UID, repo.ID); err != nil { + if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil { return err } } @@ -422,3 +426,30 @@ func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID return committer.Commit() } + +// DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner +func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error { + for { + repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{ + PageSize: repo_model.RepositoryListDefaultPageSize, + Page: 1, + }, + Private: true, + OwnerID: owner.ID, + Actor: owner, + }) + if err != nil { + return fmt.Errorf("GetUserRepositories: %w", err) + } + if len(repos) == 0 { + break + } + for _, repo := range repos { + if err := DeleteRepositoryDirectly(ctx, owner, repo.ID); err != nil { + return fmt.Errorf("unable to delete repository %s for %s[%d]. Error: %w", repo.Name, owner.Name, owner.ID, err) + } + } + } + return nil +} diff --git a/services/repository/delete_test.go b/services/repository/delete_test.go index ae5fd539621..869b8af11d5 100644 --- a/services/repository/delete_test.go +++ b/services/repository/delete_test.go @@ -1,7 +1,7 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package repository +package repository_test import ( "testing" @@ -10,6 +10,8 @@ import ( "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + repo_service "code.gitea.io/gitea/services/repository" "github.com/stretchr/testify/assert" ) @@ -19,7 +21,7 @@ func TestTeam_HasRepository(t *testing.T) { test := func(teamID, repoID int64, expected bool) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.Equal(t, expected, HasRepository(db.DefaultContext, team, repoID)) + assert.Equal(t, expected, repo_service.HasRepository(db.DefaultContext, team, repoID)) } test(1, 1, false) test(1, 3, true) @@ -35,7 +37,7 @@ func TestTeam_RemoveRepository(t *testing.T) { testSuccess := func(teamID, repoID int64) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, RemoveRepositoryFromTeam(db.DefaultContext, team, repoID)) + assert.NoError(t, repo_service.RemoveRepositoryFromTeam(db.DefaultContext, team, repoID)) unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID}) unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID}) } @@ -43,3 +45,11 @@ func TestTeam_RemoveRepository(t *testing.T) { testSuccess(2, 5) testSuccess(1, unittest.NonexistentID) } + +func TestDeleteOwnerRepositoriesDirectly(t *testing.T) { + unittest.PrepareTestEnv(t) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(db.DefaultContext, user)) +} diff --git a/services/repository/files/cherry_pick.go b/services/repository/files/cherry_pick.go index c1c5bfb6176..451a1821557 100644 --- a/services/repository/files/cherry_pick.go +++ b/services/repository/files/cherry_pick.go @@ -28,15 +28,18 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod t, err := NewTemporaryUploadRepository(ctx, repo) if err != nil { - log.Error("%v", err) + log.Error("NewTemporaryUploadRepository failed: %v", err) } defer t.Close() - if err := t.Clone(opts.OldBranch); err != nil { + if err := t.Clone(opts.OldBranch, false); err != nil { return nil, err } if err := t.SetDefaultIndex(); err != nil { return nil, err } + if err := t.RefreshIndex(); err != nil { + return nil, err + } // Get the commit of the original branch commit, err := t.GetBranchCommit(opts.OldBranch) @@ -48,7 +51,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod if opts.LastCommitID == "" { opts.LastCommitID = commit.ID.String() } else { - lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID) if err != nil { return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %w", err) } @@ -67,7 +70,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod } parent, err := commit.ParentID(0) if err != nil { - parent = git.MustIDFromString(git.EmptyTreeSHA) + parent = git.ObjectFormatFromName(repo.ObjectFormatName).EmptyTree() } base, right := parent.String(), commit.ID.String() diff --git a/services/repository/files/commit.go b/services/repository/files/commit.go index 3e4627487be..e0dad292732 100644 --- a/services/repository/files/commit.go +++ b/services/repository/files/commit.go @@ -5,57 +5,13 @@ package files import ( "context" - "fmt" asymkey_model "code.gitea.io/gitea/models/asymkey" - git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/services/automerge" ) -// CreateCommitStatus creates a new CommitStatus given a bunch of parameters -// NOTE: All text-values will be trimmed from whitespaces. -// Requires: Repo, Creator, SHA -func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, creator *user_model.User, sha string, status *git_model.CommitStatus) error { - repoPath := repo.RepoPath() - - // confirm that commit is exist - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) - if err != nil { - return fmt.Errorf("OpenRepository[%s]: %w", repoPath, err) - } - defer closer.Close() - - if commit, err := gitRepo.GetCommit(sha); err != nil { - gitRepo.Close() - return fmt.Errorf("GetCommit[%s]: %w", sha, err) - } else if len(sha) != git.SHAFullLength { - // use complete commit sha - sha = commit.ID.String() - } - gitRepo.Close() - - if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{ - Repo: repo, - Creator: creator, - SHA: sha, - CommitStatus: status, - }); err != nil { - return fmt.Errorf("NewCommitStatus[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) - } - - if status.State.IsSuccess() { - if err := automerge.MergeScheduledPullRequest(ctx, sha, repo); err != nil { - return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) - } - } - - return nil -} - // CountDivergingCommits determines how many commits a branch is ahead or behind the repository's base branch func CountDivergingCommits(ctx context.Context, repo *repo_model.Repository, branch string) (*git.DivergeObject, error) { divergence, err := git.GetDivergingCommits(ctx, repo.RepoPath(), repo.DefaultBranch, branch) diff --git a/services/repository/files/content.go b/services/repository/files/content.go index 30d62fbcdf7..95e7c7087c0 100644 --- a/services/repository/files/content.go +++ b/services/repository/files/content.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -58,7 +59,7 @@ func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, treePat } treePath = cleanTreePath - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return nil, err } @@ -133,7 +134,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref } treePath = cleanTreePath - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return nil, err } @@ -219,7 +220,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, treePath, ref } } // Handle links - if entry.IsRegular() || entry.IsLink() { + if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() { downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + url.PathEscape(string(refType)) + "/" + util.PathEscapeSegments(ref) + "/" + util.PathEscapeSegments(treePath)) if err != nil { return nil, err @@ -269,3 +270,28 @@ func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git Content: content, }, nil } + +// TryGetContentLanguage tries to get the (linguist) language of the file content +func TryGetContentLanguage(gitRepo *git.Repository, commitID, treePath string) (string, error) { + indexFilename, worktree, deleteTemporaryFile, err := gitRepo.ReadTreeToTemporaryIndex(commitID) + if err != nil { + return "", err + } + + defer deleteTemporaryFile() + + filename2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{ + CachedOnly: true, + Attributes: []string{git.AttributeLinguistLanguage, git.AttributeGitlabLanguage}, + Filenames: []string{treePath}, + IndexFile: indexFilename, + WorkTree: worktree, + }) + if err != nil { + return "", err + } + + language := git.TryReadLanguageAttribute(filename2attribute2info[treePath]) + + return language.Value(), nil +} diff --git a/services/repository/files/content_test.go b/services/repository/files/content_test.go index d591c468399..4811f9d327a 100644 --- a/services/repository/files/content_test.go +++ b/services/repository/files/content_test.go @@ -4,14 +4,12 @@ package files import ( - "path/filepath" "testing" - repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/contexttest" _ "code.gitea.io/gitea/models/actions" @@ -19,9 +17,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } func getExpectedReadmeContentsResponse() *api.ContentsResponse { @@ -238,7 +234,7 @@ func TestGetBlobBySHA(t *testing.T) { ctx.SetParams(":id", "1") ctx.SetParams(":sha", sha) - gitRepo, err := git.OpenRepository(ctx, repo_model.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)) + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) if err != nil { t.Fail() } diff --git a/services/repository/files/diff.go b/services/repository/files/diff.go index 373249b1145..bf8b938e217 100644 --- a/services/repository/files/diff.go +++ b/services/repository/files/diff.go @@ -21,7 +21,7 @@ func GetDiffPreview(ctx context.Context, repo *repo_model.Repository, branch, tr return nil, err } defer t.Close() - if err := t.Clone(branch); err != nil { + if err := t.Clone(branch, true); err != nil { return nil, err } if err := t.SetDefaultIndex(); err != nil { diff --git a/services/repository/files/diff_test.go b/services/repository/files/diff_test.go index 91c878e5053..63aff9b0e30 100644 --- a/services/repository/files/diff_test.go +++ b/services/repository/files/diff_test.go @@ -8,8 +8,8 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/gitdiff" "github.com/stretchr/testify/assert" diff --git a/services/repository/files/file_test.go b/services/repository/files/file_test.go index 4e67ad14108..a5b3aad91e3 100644 --- a/services/repository/files/file_test.go +++ b/services/repository/files/file_test.go @@ -7,10 +7,10 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -109,7 +109,7 @@ func TestGetFileResponseFromCommit(t *testing.T) { repo := ctx.Repo.Repository branch := repo.DefaultBranch treePath := "README.md" - gitRepo, _ := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, _ := gitrepo.OpenRepository(ctx, repo) defer gitRepo.Close() commit, _ := gitRepo.GetBranchCommit(branch) expectedFileResponse := getExpectedFileResponse() diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go index fdf0b32f1a1..e5f7e2af965 100644 --- a/services/repository/files/patch.go +++ b/services/repository/files/patch.go @@ -13,6 +13,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/structs" asymkey_service "code.gitea.io/gitea/services/asymkey" @@ -42,7 +43,7 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode opts.NewBranch = opts.OldBranch } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return err } @@ -95,6 +96,11 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode // ApplyDiffPatch applies a patch to the given repository func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) { + err := repo.MustNotBeArchived() + if err != nil { + return nil, err + } + if err := opts.Validate(ctx, repo, doer); err != nil { return nil, err } @@ -105,10 +111,10 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user t, err := NewTemporaryUploadRepository(ctx, repo) if err != nil { - log.Error("%v", err) + log.Error("NewTemporaryUploadRepository failed: %v", err) } defer t.Close() - if err := t.Clone(opts.OldBranch); err != nil { + if err := t.Clone(opts.OldBranch, true); err != nil { return nil, err } if err := t.SetDefaultIndex(); err != nil { @@ -125,7 +131,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user if opts.LastCommitID == "" { opts.LastCommitID = commit.ID.String() } else { - lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID) if err != nil { return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %w", err) } diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index 7f6b8137ae1..9fcd335c55a 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -51,8 +51,13 @@ func (t *TemporaryUploadRepository) Close() { } // Clone the base repository to our path and set branch as the HEAD -func (t *TemporaryUploadRepository) Clone(branch string) error { - if _, _, err := git.NewCommand(t.ctx, "clone", "-s", "--bare", "-b").AddDynamicArguments(branch, t.repo.RepoPath(), t.basePath).RunStdString(nil); err != nil { +func (t *TemporaryUploadRepository) Clone(branch string, bare bool) error { + cmd := git.NewCommand(t.ctx, "clone", "-s", "-b").AddDynamicArguments(branch, t.repo.RepoPath(), t.basePath) + if bare { + cmd.AddArguments("--bare") + } + + if _, _, err := cmd.RunStdString(nil); err != nil { stderr := err.Error() if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched { return git.ErrBranchNotExist{ @@ -65,9 +70,8 @@ func (t *TemporaryUploadRepository) Clone(branch string) error { OwnerName: t.repo.OwnerName, Name: t.repo.Name, } - } else { - return fmt.Errorf("Clone: %w %s", err, stderr) } + return fmt.Errorf("Clone: %w %s", err, stderr) } gitRepo, err := git.OpenRepository(t.ctx, t.basePath) if err != nil { @@ -78,8 +82,8 @@ func (t *TemporaryUploadRepository) Clone(branch string) error { } // Init the repository -func (t *TemporaryUploadRepository) Init() error { - if err := git.InitRepository(t.ctx, t.basePath, false); err != nil { +func (t *TemporaryUploadRepository) Init(objectFormatName string) error { + if err := git.InitRepository(t.ctx, t.basePath, false, objectFormatName); err != nil { return err } gitRepo, err := git.OpenRepository(t.ctx, t.basePath) @@ -98,6 +102,14 @@ func (t *TemporaryUploadRepository) SetDefaultIndex() error { return nil } +// RefreshIndex looks at the current index and checks to see if merges or updates are needed by checking stat() information. +func (t *TemporaryUploadRepository) RefreshIndex() error { + if _, _, err := git.NewCommand(t.ctx, "update-index", "--refresh").RunStdString(&git.RunOpts{Dir: t.basePath}); err != nil { + return fmt.Errorf("RefreshIndex: %w", err) + } + return nil +} + // LsFiles checks if the given filename arguments are in the index func (t *TemporaryUploadRepository) LsFiles(filenames ...string) ([]string, error) { stdOut := new(bytes.Buffer) @@ -343,7 +355,7 @@ func (t *TemporaryUploadRepository) DiffIndex() (*gitdiff.Diff, error) { Stderr: stderr, PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { _ = stdoutWriter.Close() - diff, finalErr = gitdiff.ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, stdoutReader, "") + diff, finalErr = gitdiff.ParsePatch(t.ctx, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, stdoutReader, "") if finalErr != nil { log.Error("ParsePatch: %v", finalErr) cancel() diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go index 0b1d304845e..e3a7f3b8b0e 100644 --- a/services/repository/files/tree.go +++ b/services/repository/files/tree.go @@ -37,19 +37,21 @@ func GetTreeBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git } apiURL := repo.APIURL() apiURLLen := len(apiURL) + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) + hashLen := objectFormat.FullLength() - // 51 is len(sha1) + len("/git/blobs/"). 40 + 11. - blobURL := make([]byte, apiURLLen+51) + const gitBlobsPath = "/git/blobs/" + blobURL := make([]byte, apiURLLen+hashLen+len(gitBlobsPath)) copy(blobURL, apiURL) - copy(blobURL[apiURLLen:], "/git/blobs/") + copy(blobURL[apiURLLen:], []byte(gitBlobsPath)) - // 51 is len(sha1) + len("/git/trees/"). 40 + 11. - treeURL := make([]byte, apiURLLen+51) + const gitTreePath = "/git/trees/" + treeURL := make([]byte, apiURLLen+hashLen+len(gitTreePath)) copy(treeURL, apiURL) - copy(treeURL[apiURLLen:], "/git/trees/") + copy(treeURL[apiURLLen:], []byte(gitTreePath)) - // 40 is the size of the sha1 hash in hexadecimal format. - copyPos := len(treeURL) - git.SHAFullLength + // copyPos is at the start of the hash + copyPos := len(treeURL) - hashLen if perPage <= 0 || perPage > setting.API.DefaultGitTreesPerPage { perPage = setting.API.DefaultGitTreesPerPage diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go index 528ef500df2..508f20090dd 100644 --- a/services/repository/files/tree_test.go +++ b/services/repository/files/tree_test.go @@ -7,8 +7,8 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/contexttest" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) diff --git a/services/repository/files/update.go b/services/repository/files/update.go index f01092d360d..f029a9aefe9 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -16,6 +16,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -39,7 +40,7 @@ type ChangeRepoFile struct { Operation string TreePath string FromTreePath string - ContentReader io.Reader + ContentReader io.ReadSeeker SHA string Options *RepoFileOptions } @@ -65,6 +66,11 @@ type RepoFileOptions struct { // ChangeRepoFiles adds, updates or removes multiple files in the given repository func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (*structs.FilesResponse, error) { + err := repo.MustNotBeArchived() + if err != nil { + return nil, err + } + // If no branch name is set, assume default branch if opts.OldBranch == "" { opts.OldBranch = repo.DefaultBranch @@ -73,7 +79,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use opts.NewBranch = opts.OldBranch } - gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, repo.RepoPath()) + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) if err != nil { return nil, err } @@ -137,11 +143,11 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use t, err := NewTemporaryUploadRepository(ctx, repo) if err != nil { - log.Error("%v", err) + log.Error("NewTemporaryUploadRepository failed: %v", err) } defer t.Close() hasOldBranch := true - if err := t.Clone(opts.OldBranch); err != nil { + if err := t.Clone(opts.OldBranch, true); err != nil { for _, file := range opts.Files { if file.Operation == "delete" { return nil, err @@ -150,7 +156,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use if !git.IsErrBranchNotExist(err) || !repo.IsEmpty { return nil, err } - if err := t.Init(); err != nil { + if err := t.Init(repo.ObjectFormatName); err != nil { return nil, err } hasOldBranch = false @@ -197,7 +203,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use if opts.LastCommitID == "" { opts.LastCommitID = commit.ID.String() } else { - lastCommitID, err := t.gitRepo.ConvertToSHA1(opts.LastCommitID) + lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID) if err != nil { return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %w", err) } @@ -433,7 +439,7 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file if lfsMetaObject != nil { // We have an LFS object - create it - lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject) + lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject.RepositoryID, lfsMetaObject.Pointer) if err != nil { return err } @@ -442,6 +448,10 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file return err } if !exist { + _, err := file.ContentReader.Seek(0, io.SeekStart) + if err != nil { + return err + } if err := contentStore.Put(lfsMetaObject.Pointer, file.ContentReader); err != nil { if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil { return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err) diff --git a/services/repository/files/upload.go b/services/repository/files/upload.go index f4e1da7bb1a..cbfaf49d131 100644 --- a/services/repository/files/upload.go +++ b/services/repository/files/upload.go @@ -87,11 +87,11 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use defer t.Close() hasOldBranch := true - if err = t.Clone(opts.OldBranch); err != nil { + if err = t.Clone(opts.OldBranch, true); err != nil { if !git.IsErrBranchNotExist(err) || !repo.IsEmpty { return err } - if err = t.Init(); err != nil { + if err = t.Init(repo.ObjectFormatName); err != nil { return err } hasOldBranch = false @@ -143,7 +143,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use if infos[i].lfsMetaObject == nil { continue } - infos[i].lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, infos[i].lfsMetaObject) + infos[i].lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, infos[i].lfsMetaObject.RepositoryID, infos[i].lfsMetaObject.Pointer) if err != nil { // OK Now we need to cleanup return cleanUpAfterFailure(ctx, &infos, t, err) diff --git a/services/repository/fork.go b/services/repository/fork.go index 4e0fbc38ac3..f074fd10821 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -14,6 +14,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/structs" @@ -44,13 +45,22 @@ func (err ErrForkAlreadyExist) Unwrap() error { // ForkRepoOptions contains the fork repository options type ForkRepoOptions struct { - BaseRepo *repo_model.Repository - Name string - Description string + BaseRepo *repo_model.Repository + Name string + Description string + SingleBranch string } // ForkRepository forks a repository func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts ForkRepoOptions) (*repo_model.Repository, error) { + if err := opts.BaseRepo.LoadOwner(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, opts.BaseRepo.Owner.ID) { + return nil, user_model.ErrBlockedUser + } + // Fork is prohibited, if user has reached maximum limit of repositories if !owner.CanForkRepo() { return nil, repo_model.ErrReachLimitOfRepo{ @@ -70,18 +80,23 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork } } + defaultBranch := opts.BaseRepo.DefaultBranch + if opts.SingleBranch != "" { + defaultBranch = opts.SingleBranch + } repo := &repo_model.Repository{ - OwnerID: owner.ID, - Owner: owner, - OwnerName: owner.Name, - Name: opts.Name, - LowerName: strings.ToLower(opts.Name), - Description: opts.Description, - DefaultBranch: opts.BaseRepo.DefaultBranch, - IsPrivate: opts.BaseRepo.IsPrivate || opts.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate, - IsEmpty: opts.BaseRepo.IsEmpty, - IsFork: true, - ForkID: opts.BaseRepo.ID, + OwnerID: owner.ID, + Owner: owner, + OwnerName: owner.Name, + Name: opts.Name, + LowerName: strings.ToLower(opts.Name), + Description: opts.Description, + DefaultBranch: defaultBranch, + IsPrivate: opts.BaseRepo.IsPrivate || opts.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate, + IsEmpty: opts.BaseRepo.IsEmpty, + IsFork: true, + ForkID: opts.BaseRepo.ID, + ObjectFormatName: opts.BaseRepo.ObjectFormatName, } oldRepoPath := opts.BaseRepo.RepoPath() @@ -134,9 +149,12 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork needsRollback = true + cloneCmd := git.NewCommand(txCtx, "clone", "--bare") + if opts.SingleBranch != "" { + cloneCmd.AddArguments("--single-branch", "--branch").AddDynamicArguments(opts.SingleBranch) + } repoPath := repo_model.RepoPath(owner.Name, repo.Name) - if stdout, _, err := git.NewCommand(txCtx, - "clone", "--bare").AddDynamicArguments(oldRepoPath, repoPath). + if stdout, _, err := cloneCmd.AddDynamicArguments(oldRepoPath, repoPath). SetDescription(fmt.Sprintf("ForkRepository(git clone): %s to %s", opts.BaseRepo.FullName(), repo.FullName())). RunStdBytes(&git.RunOpts{Timeout: 10 * time.Minute}); err != nil { log.Error("Fork Repository (git clone) Failed for %v (from %v):\nStdout: %s\nError: %v", repo, opts.BaseRepo, stdout, err) @@ -158,7 +176,7 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork return fmt.Errorf("createDelegateHooks: %w", err) } - gitRepo, err := git.OpenRepository(txCtx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(txCtx, repo) if err != nil { return fmt.Errorf("OpenRepository: %w", err) } @@ -177,16 +195,16 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork if err := repo_module.UpdateRepoSize(ctx, repo); err != nil { log.Error("Failed to update size for repository: %v", err) } - if err := repo_model.CopyLanguageStat(opts.BaseRepo, repo); err != nil { + if err := repo_model.CopyLanguageStat(ctx, opts.BaseRepo, repo); err != nil { log.Error("Copy language stat from oldRepo failed: %v", err) } - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { log.Error("Open created git repository failed: %v", err) } else { defer gitRepo.Close() - if err := repo_module.SyncReleasesWithTags(repo, gitRepo); err != nil { + if err := repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { log.Error("Sync releases from git tags failed: %v", err) } } diff --git a/modules/repository/generate.go b/services/repository/generate.go similarity index 88% rename from modules/repository/generate.go rename to services/repository/generate.go index 4055029d22a..9b09e271ab4 100644 --- a/modules/repository/generate.go +++ b/services/repository/generate.go @@ -19,7 +19,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/util" "github.com/gobwas/glob" @@ -93,7 +95,7 @@ type GiteaTemplate struct { } // Globs parses the .gitea/template globs or returns them if they were already parsed -func (gt GiteaTemplate) Globs() []glob.Glob { +func (gt *GiteaTemplate) Globs() []glob.Glob { if gt.globs != nil { return gt.globs } @@ -223,7 +225,7 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r } } - if err := git.InitRepository(ctx, tmpDir, false); err != nil { + if err := git.InitRepository(ctx, tmpDir, false, templateRepo.ObjectFormatName); err != nil { return err } @@ -241,7 +243,7 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r defaultBranch = templateRepo.DefaultBranch } - return InitRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch) + return initRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch) } func generateGitContent(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository) (err error) { @@ -270,12 +272,7 @@ func generateGitContent(ctx context.Context, repo, templateRepo, generateRepo *r repo.DefaultBranch = templateRepo.DefaultBranch } - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) - if err != nil { - return fmt.Errorf("openRepository: %w", err) - } - defer gitRepo.Close() - if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { return fmt.Errorf("setDefaultBranch: %w", err) } if err = UpdateRepository(ctx, repo, false); err != nil { @@ -291,7 +288,7 @@ func GenerateGitContent(ctx context.Context, templateRepo, generateRepo *repo_mo return err } - if err := UpdateRepoSize(ctx, generateRepo); err != nil { + if err := repo_module.UpdateRepoSize(ctx, generateRepo); err != nil { return fmt.Errorf("failed to update size for repository: %w", err) } @@ -322,24 +319,25 @@ func (gro GenerateRepoOptions) IsValid() bool { gro.IssueLabels || gro.ProtectedBranch // or other items as they are added } -// GenerateRepository generates a repository from a template -func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) { +// generateRepository generates a repository from a template +func generateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) { generateRepo := &repo_model.Repository{ - OwnerID: owner.ID, - Owner: owner, - OwnerName: owner.Name, - Name: opts.Name, - LowerName: strings.ToLower(opts.Name), - Description: opts.Description, - DefaultBranch: opts.DefaultBranch, - IsPrivate: opts.Private, - IsEmpty: !opts.GitContent || templateRepo.IsEmpty, - IsFsckEnabled: templateRepo.IsFsckEnabled, - TemplateID: templateRepo.ID, - TrustModel: templateRepo.TrustModel, + OwnerID: owner.ID, + Owner: owner, + OwnerName: owner.Name, + Name: opts.Name, + LowerName: strings.ToLower(opts.Name), + Description: opts.Description, + DefaultBranch: opts.DefaultBranch, + IsPrivate: opts.Private, + IsEmpty: !opts.GitContent || templateRepo.IsEmpty, + IsFsckEnabled: templateRepo.IsFsckEnabled, + TemplateID: templateRepo.ID, + TrustModel: templateRepo.TrustModel, + ObjectFormatName: templateRepo.ObjectFormatName, } - if err = CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil { + if err = repo_module.CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil { return nil, err } @@ -356,11 +354,11 @@ func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templ } } - if err = CheckInitRepository(ctx, owner.Name, generateRepo.Name); err != nil { + if err = repo_module.CheckInitRepository(ctx, owner.Name, generateRepo.Name, generateRepo.ObjectFormatName); err != nil { return generateRepo, err } - if err = CheckDaemonExportOK(ctx, generateRepo); err != nil { + if err = repo_module.CheckDaemonExportOK(ctx, generateRepo); err != nil { return generateRepo, fmt.Errorf("checkDaemonExportOK: %w", err) } diff --git a/modules/repository/generate_test.go b/services/repository/generate_test.go similarity index 100% rename from modules/repository/generate_test.go rename to services/repository/generate_test.go diff --git a/services/repository/hooks.go b/services/repository/hooks.go index 8506fa34136..97e9e290a3e 100644 --- a/services/repository/hooks.go +++ b/services/repository/hooks.go @@ -10,7 +10,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/webhook" - "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" @@ -52,13 +52,13 @@ func SyncRepositoryHooks(ctx context.Context) error { // GenerateGitHooks generates git hooks from a template repository func GenerateGitHooks(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error { - generateGitRepo, err := git.OpenRepository(ctx, generateRepo.RepoPath()) + generateGitRepo, err := gitrepo.OpenRepository(ctx, generateRepo) if err != nil { return err } defer generateGitRepo.Close() - templateGitRepo, err := git.OpenRepository(ctx, templateRepo.RepoPath()) + templateGitRepo, err := gitrepo.OpenRepository(ctx, templateRepo) if err != nil { return err } @@ -85,7 +85,7 @@ func GenerateGitHooks(ctx context.Context, templateRepo, generateRepo *repo_mode // GenerateWebhooks generates webhooks from a template repository func GenerateWebhooks(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error { - templateWebhooks, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{RepoID: templateRepo.ID}) + templateWebhooks, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{RepoID: templateRepo.ID}) if err != nil { return err } diff --git a/services/repository/init.go b/services/repository/init.go new file mode 100644 index 00000000000..817fa4abd77 --- /dev/null +++ b/services/repository/init.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "fmt" + "os" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + asymkey_service "code.gitea.io/gitea/services/asymkey" +) + +// initRepoCommit temporarily changes with work directory. +func initRepoCommit(ctx context.Context, tmpPath string, repo *repo_model.Repository, u *user_model.User, defaultBranch string) (err error) { + commitTimeStr := time.Now().Format(time.RFC3339) + + sig := u.NewGitSig() + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+sig.Name, + "GIT_AUTHOR_EMAIL="+sig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + committerName := sig.Name + committerEmail := sig.Email + + if stdout, _, err := git.NewCommand(ctx, "add", "--all"). + SetDescription(fmt.Sprintf("initRepoCommit (git add): %s", tmpPath)). + RunStdString(&git.RunOpts{Dir: tmpPath}); err != nil { + log.Error("git add --all failed: Stdout: %s\nError: %v", stdout, err) + return fmt.Errorf("git add --all: %w", err) + } + + cmd := git.NewCommand(ctx, "commit", "--message=Initial commit"). + AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email) + + sign, keyID, signer, _ := asymkey_service.SignInitialCommit(ctx, tmpPath, u) + if sign { + cmd.AddOptionFormat("-S%s", keyID) + + if repo.GetTrustModel() == repo_model.CommitterTrustModel || repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel { + // need to set the committer to the KeyID owner + committerName = signer.Name + committerEmail = signer.Email + } + } else { + cmd.AddArguments("--no-gpg-sign") + } + + env = append(env, + "GIT_COMMITTER_NAME="+committerName, + "GIT_COMMITTER_EMAIL="+committerEmail, + ) + + if stdout, _, err := cmd. + SetDescription(fmt.Sprintf("initRepoCommit (git commit): %s", tmpPath)). + RunStdString(&git.RunOpts{Dir: tmpPath, Env: env}); err != nil { + log.Error("Failed to commit: %v: Stdout: %s\nError: %v", cmd.String(), stdout, err) + return fmt.Errorf("git commit: %w", err) + } + + if len(defaultBranch) == 0 { + defaultBranch = setting.Repository.DefaultBranch + } + + if stdout, _, err := git.NewCommand(ctx, "push", "origin").AddDynamicArguments("HEAD:" + defaultBranch). + SetDescription(fmt.Sprintf("initRepoCommit (git push): %s", tmpPath)). + RunStdString(&git.RunOpts{Dir: tmpPath, Env: repo_module.InternalPushingEnvironment(u, repo)}); err != nil { + log.Error("Failed to push back to HEAD: Stdout: %s\nError: %v", stdout, err) + return fmt.Errorf("git push: %w", err) + } + + return nil +} diff --git a/services/repository/lfs.go b/services/repository/lfs.go index 8e654b6f13b..4d48881b87f 100644 --- a/services/repository/lfs.go +++ b/services/repository/lfs.go @@ -12,6 +12,7 @@ import ( git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -69,7 +70,7 @@ func GarbageCollectLFSMetaObjectsForRepo(ctx context.Context, repo *repo_model.R } }() - gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { log.Error("Unable to open git repository %-v: %v", repo, err) return err @@ -78,13 +79,14 @@ func GarbageCollectLFSMetaObjectsForRepo(ctx context.Context, repo *repo_model.R store := lfs.NewContentStore() errStop := errors.New("STOPERR") + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) err = git_model.IterateLFSMetaObjectsForRepo(ctx, repo.ID, func(ctx context.Context, metaObject *git_model.LFSMetaObject, count int64) error { if opts.NumberToCheckPerRepo > 0 && total > opts.NumberToCheckPerRepo { return errStop } total++ - pointerSha := git.ComputeBlobHash([]byte(metaObject.Pointer.StringContent())) + pointerSha := git.ComputeBlobHash(objectFormat, []byte(metaObject.Pointer.StringContent())) if gitRepo.IsObjectExist(pointerSha.String()) { return git_model.MarkLFSMetaObject(ctx, metaObject.ID) diff --git a/services/repository/lfs_test.go b/services/repository/lfs_test.go index e88befdfefe..ee0b8f6b895 100644 --- a/services/repository/lfs_test.go +++ b/services/repository/lfs_test.go @@ -1,7 +1,7 @@ // Copyright 2023 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package repository +package repository_test import ( "bytes" @@ -16,12 +16,13 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" + repo_service "code.gitea.io/gitea/services/repository" "github.com/stretchr/testify/assert" ) func TestGarbageCollectLFSMetaObjects(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + unittest.PrepareTestEnv(t) setting.LFS.StartServer = true err := storage.Init() @@ -35,7 +36,7 @@ func TestGarbageCollectLFSMetaObjects(t *testing.T) { lfsOid := storeObjectInRepo(t, repo.ID, &lfsContent) // gc - err = GarbageCollectLFSMetaObjects(context.Background(), GarbageCollectLFSMetaObjectsOptions{ + err = repo_service.GarbageCollectLFSMetaObjects(context.Background(), repo_service.GarbageCollectLFSMetaObjectsOptions{ AutoFix: true, OlderThan: time.Now().Add(7 * 24 * time.Hour).Add(5 * 24 * time.Hour), UpdatedLessRecentlyThan: time.Now().Add(7 * 24 * time.Hour).Add(3 * 24 * time.Hour), @@ -51,7 +52,7 @@ func storeObjectInRepo(t *testing.T, repositoryID int64, content *[]byte) string pointer, err := lfs.GeneratePointer(bytes.NewReader(*content)) assert.NoError(t, err) - _, err = git_model.NewLFSMetaObject(db.DefaultContext, &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repositoryID}) + _, err = git_model.NewLFSMetaObject(db.DefaultContext, repositoryID, pointer) assert.NoError(t, err) contentStore := lfs.NewContentStore() exist, err := contentStore.Exists(pointer) diff --git a/services/repository/main_test.go b/services/repository/main_test.go index 007790f2a96..7ad1540aee4 100644 --- a/services/repository/main_test.go +++ b/services/repository/main_test.go @@ -4,14 +4,11 @@ package repository import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/repository/migrate.go b/services/repository/migrate.go new file mode 100644 index 00000000000..df5cc67ae1c --- /dev/null +++ b/services/repository/migrate.go @@ -0,0 +1,285 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/migration" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +func cloneWiki(ctx context.Context, u *user_model.User, opts migration.MigrateOptions, migrateTimeout time.Duration) (string, error) { + wikiPath := repo_model.WikiPath(u.Name, opts.RepoName) + wikiRemotePath := repo_module.WikiRemoteURL(ctx, opts.CloneAddr) + if wikiRemotePath == "" { + return "", nil + } + + if err := util.RemoveAll(wikiPath); err != nil { + return "", fmt.Errorf("failed to remove existing wiki dir %q, err: %w", wikiPath, err) + } + + cleanIncompleteWikiPath := func() { + if err := util.RemoveAll(wikiPath); err != nil { + log.Error("Failed to remove incomplete wiki dir %q, err: %v", wikiPath, err) + } + } + if err := git.Clone(ctx, wikiRemotePath, wikiPath, git.CloneRepoOptions{ + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + SkipTLSVerify: setting.Migrations.SkipTLSVerify, + }); err != nil { + log.Error("Clone wiki failed, err: %v", err) + cleanIncompleteWikiPath() + return "", err + } + + if err := git.WriteCommitGraph(ctx, wikiPath); err != nil { + cleanIncompleteWikiPath() + return "", err + } + + defaultBranch, err := git.GetDefaultBranch(ctx, wikiPath) + if err != nil { + cleanIncompleteWikiPath() + return "", fmt.Errorf("failed to get wiki repo default branch for %q, err: %w", wikiPath, err) + } + + return defaultBranch, nil +} + +// MigrateRepositoryGitData starts migrating git related data after created migrating repository +func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, + repo *repo_model.Repository, opts migration.MigrateOptions, + httpTransport *http.Transport, +) (*repo_model.Repository, error) { + repoPath := repo_model.RepoPath(u.Name, opts.RepoName) + + if u.IsOrganization() { + t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx) + if err != nil { + return nil, err + } + repo.NumWatches = t.NumMembers + } else { + repo.NumWatches = 1 + } + + migrateTimeout := time.Duration(setting.Git.Timeout.Migrate) * time.Second + + if err := util.RemoveAll(repoPath); err != nil { + return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repoPath, err) + } + + if err := git.Clone(ctx, opts.CloneAddr, repoPath, git.CloneRepoOptions{ + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + SkipTLSVerify: setting.Migrations.SkipTLSVerify, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err) + } + return repo, fmt.Errorf("clone error: %w", err) + } + + if err := git.WriteCommitGraph(ctx, repoPath); err != nil { + return repo, err + } + + if opts.Wiki { + defaultWikiBranch, err := cloneWiki(ctx, u, opts, migrateTimeout) + if err != nil { + return repo, fmt.Errorf("clone wiki error: %w", err) + } + repo.DefaultWikiBranch = defaultWikiBranch + } + + if repo.OwnerID == u.ID { + repo.Owner = u + } + + if err := repo_module.CheckDaemonExportOK(ctx, repo); err != nil { + return repo, fmt.Errorf("checkDaemonExportOK: %w", err) + } + + if stdout, _, err := git.NewCommand(ctx, "update-server-info"). + SetDescription(fmt.Sprintf("MigrateRepositoryGitData(git update-server-info): %s", repoPath)). + RunStdString(&git.RunOpts{Dir: repoPath}); err != nil { + log.Error("MigrateRepositoryGitData(git update-server-info) in %v: Stdout: %s\nError: %v", repo, stdout, err) + return repo, fmt.Errorf("error in MigrateRepositoryGitData(git update-server-info): %w", err) + } + + gitRepo, err := git.OpenRepository(ctx, repoPath) + if err != nil { + return repo, fmt.Errorf("OpenRepository: %w", err) + } + defer gitRepo.Close() + + repo.IsEmpty, err = gitRepo.IsEmpty() + if err != nil { + return repo, fmt.Errorf("git.IsEmpty: %w", err) + } + + if !repo.IsEmpty { + if len(repo.DefaultBranch) == 0 { + // Try to get HEAD branch and set it as default branch. + headBranch, err := gitRepo.GetHEADBranch() + if err != nil { + return repo, fmt.Errorf("GetHEADBranch: %w", err) + } + if headBranch != nil { + repo.DefaultBranch = headBranch.Name + } + } + + if _, err := repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, u.ID); err != nil { + return repo, fmt.Errorf("SyncRepoBranchesWithRepo: %v", err) + } + + if !opts.Releases { + // note: this will greatly improve release (tag) sync + // for pull-mirrors with many tags + repo.IsMirror = opts.Mirror + if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { + log.Error("Failed to synchronize tags to releases for repository: %v", err) + } + } + + if opts.LFS { + endpoint := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint) + lfsClient := lfs.NewClient(endpoint, httpTransport) + if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil { + log.Error("Failed to store missing LFS objects for repository: %v", err) + } + } + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + if opts.Mirror { + remoteAddress, err := util.SanitizeURL(opts.CloneAddr) + if err != nil { + return repo, err + } + mirrorModel := repo_model.Mirror{ + RepoID: repo.ID, + Interval: setting.Mirror.DefaultInterval, + EnablePrune: true, + NextUpdateUnix: timeutil.TimeStampNow().AddDuration(setting.Mirror.DefaultInterval), + LFS: opts.LFS, + RemoteAddress: remoteAddress, + } + if opts.LFS { + mirrorModel.LFSEndpoint = opts.LFSEndpoint + } + + if opts.MirrorInterval != "" { + parsedInterval, err := time.ParseDuration(opts.MirrorInterval) + if err != nil { + log.Error("Failed to set Interval: %v", err) + return repo, err + } + if parsedInterval == 0 { + mirrorModel.Interval = 0 + mirrorModel.NextUpdateUnix = 0 + } else if parsedInterval < setting.Mirror.MinInterval { + err := fmt.Errorf("interval %s is set below Minimum Interval of %s", parsedInterval, setting.Mirror.MinInterval) + log.Error("Interval: %s is too frequent", opts.MirrorInterval) + return repo, err + } else { + mirrorModel.Interval = parsedInterval + mirrorModel.NextUpdateUnix = timeutil.TimeStampNow().AddDuration(parsedInterval) + } + } + + if err = repo_model.InsertMirror(ctx, &mirrorModel); err != nil { + return repo, fmt.Errorf("InsertOne: %w", err) + } + + repo.IsMirror = true + if err = UpdateRepository(ctx, repo, false); err != nil { + return nil, err + } + + // this is necessary for sync local tags from remote + configName := fmt.Sprintf("remote.%s.fetch", mirrorModel.GetRemoteName()) + if stdout, _, err := git.NewCommand(ctx, "config"). + AddOptionValues("--add", configName, `+refs/tags/*:refs/tags/*`). + RunStdString(&git.RunOpts{Dir: repoPath}); err != nil { + log.Error("MigrateRepositoryGitData(git config --add +refs/tags/*:refs/tags/*) in %v: Stdout: %s\nError: %v", repo, stdout, err) + return repo, fmt.Errorf("error in MigrateRepositoryGitData(git config --add +refs/tags/*:refs/tags/*): %w", err) + } + } else { + if err = repo_module.UpdateRepoSize(ctx, repo); err != nil { + log.Error("Failed to update size for repository: %v", err) + } + if repo, err = CleanUpMigrateInfo(ctx, repo); err != nil { + return nil, err + } + } + + return repo, committer.Commit() +} + +// cleanUpMigrateGitConfig removes mirror info which prevents "push --all". +// This also removes possible user credentials. +func cleanUpMigrateGitConfig(ctx context.Context, repoPath string) error { + cmd := git.NewCommand(ctx, "remote", "rm", "origin") + // if the origin does not exist + _, stderr, err := cmd.RunStdString(&git.RunOpts{ + Dir: repoPath, + }) + if err != nil && !strings.HasPrefix(stderr, "fatal: No such remote") { + return err + } + return nil +} + +// CleanUpMigrateInfo finishes migrating repository and/or wiki with things that don't need to be done for mirrors. +func CleanUpMigrateInfo(ctx context.Context, repo *repo_model.Repository) (*repo_model.Repository, error) { + repoPath := repo.RepoPath() + if err := repo_module.CreateDelegateHooks(repoPath); err != nil { + return repo, fmt.Errorf("createDelegateHooks: %w", err) + } + if repo.HasWiki() { + if err := repo_module.CreateDelegateHooks(repo.WikiPath()); err != nil { + return repo, fmt.Errorf("createDelegateHooks.(wiki): %w", err) + } + } + + _, _, err := git.NewCommand(ctx, "remote", "rm", "origin").RunStdString(&git.RunOpts{Dir: repoPath}) + if err != nil && !strings.HasPrefix(err.Error(), "exit status 128 - fatal: No such remote ") { + return repo, fmt.Errorf("CleanUpMigrateInfo: %w", err) + } + + if repo.HasWiki() { + if err := cleanUpMigrateGitConfig(ctx, repo.WikiPath()); err != nil { + return repo, fmt.Errorf("cleanUpMigrateGitConfig (wiki): %w", err) + } + } + + return repo, UpdateRepository(ctx, repo, false) +} diff --git a/services/repository/push.go b/services/repository/push.go index 9b00b57e71e..39843249a53 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -11,11 +11,11 @@ import ( "time" "code.gitea.io/gitea/models/db" - git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" @@ -35,7 +35,9 @@ var pushQueue *queue.WorkerPoolQueue[[]*repo_module.PushUpdateOptions] func handler(items ...[]*repo_module.PushUpdateOptions) [][]*repo_module.PushUpdateOptions { for _, opts := range items { if err := pushUpdates(opts); err != nil { - log.Error("pushUpdate failed: %v", err) + // Username and repository stays the same between items in opts. + pushUpdate := opts[0] + log.Error("pushUpdate[%s/%s] failed: %v", pushUpdate.RepoUserName, pushUpdate.RepoName, err) } } return nil @@ -63,7 +65,7 @@ func PushUpdates(opts []*repo_module.PushUpdateOptions) error { for _, opt := range opts { if opt.IsNewRef() && opt.IsDelRef() { - return fmt.Errorf("Old and new revisions are both %s", git.EmptySHA) + return fmt.Errorf("Old and new revisions are both NULL") } } @@ -84,11 +86,9 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { return fmt.Errorf("GetRepositoryByOwnerAndName failed: %w", err) } - repoPath := repo.RepoPath() - - gitRepo, err := git.OpenRepository(ctx, repoPath) + gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { - return fmt.Errorf("OpenRepository[%s]: %w", repoPath, err) + return fmt.Errorf("OpenRepository[%s]: %w", repo.FullName(), err) } defer gitRepo.Close() @@ -99,12 +99,13 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { addTags := make([]string, 0, len(optsList)) delTags := make([]string, 0, len(optsList)) var pusher *user_model.User + objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) for _, opts := range optsList { log.Trace("pushUpdates: %-v %s %s %s", repo, opts.OldCommitID, opts.NewCommitID, opts.RefFullName) if opts.IsNewRef() && opts.IsDelRef() { - return fmt.Errorf("old and new revisions are both %s", git.EmptySHA) + return fmt.Errorf("old and new revisions are both %s", objectFormat.EmptyObjectID()) } if opts.RefFullName.IsTag() { if pusher == nil || pusher.ID != opts.PusherID { @@ -124,7 +125,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromTag(tagName), OldCommitID: opts.OldCommitID, - NewCommitID: git.EmptySHA, + NewCommitID: objectFormat.EmptyObjectID().String(), }, repo_module.NewPushCommits()) delTags = append(delTags, tagName) @@ -137,13 +138,13 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { commits := repo_module.NewPushCommits() commits.HeadCommit = repo_module.CommitToPushCommit(newCommit) - commits.CompareURL = repo.ComposeCompareURL(git.EmptySHA, opts.NewCommitID) + commits.CompareURL = repo.ComposeCompareURL(objectFormat.EmptyObjectID().String(), opts.NewCommitID) notify_service.PushCommits( ctx, pusher, repo, &repo_module.PushUpdateOptions{ RefFullName: opts.RefFullName, - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: opts.NewCommitID, }, commits) @@ -181,7 +182,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { repo.DefaultBranch = refName repo.IsEmpty = false if repo.DefaultBranch != setting.Repository.DefaultBranch { - if err := gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil { + if err := gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { if !git.IsErrUnsupportedVersion(err) { return err } @@ -219,6 +220,11 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } } + // delete cache for divergence + if err := DelDivergenceFromCache(repo.ID, branch); err != nil { + log.Error("DelDivergenceFromCache: %v", err) + } + commits := repo_module.GitToPushCommits(l) commits.HeadCommit = repo_module.CommitToPushCommit(newCommit) @@ -227,7 +233,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } oldCommitID := opts.OldCommitID - if oldCommitID == git.EmptySHA && len(commits.Commits) > 0 { + if oldCommitID == objectFormat.EmptyObjectID().String() && len(commits.Commits) > 0 { oldCommit, err := gitRepo.GetCommit(commits.Commits[len(commits.Commits)-1].Sha1) if err != nil && !git.IsErrNotExist(err) { log.Error("unable to GetCommit %s from %-v: %v", oldCommitID, repo, err) @@ -243,11 +249,11 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } } - if oldCommitID == git.EmptySHA && repo.DefaultBranch != branch { + if oldCommitID == objectFormat.EmptyObjectID().String() && repo.DefaultBranch != branch { oldCommitID = repo.DefaultBranch } - if oldCommitID != git.EmptySHA { + if oldCommitID != objectFormat.EmptyObjectID().String() { commits.CompareURL = repo.ComposeCompareURL(oldCommitID, opts.NewCommitID) } else { commits.CompareURL = "" @@ -257,10 +263,6 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { commits.Commits = commits.Commits[:setting.UI.FeedMaxCommitNum] } - if err = git_model.UpdateBranch(ctx, repo.ID, opts.PusherID, branch, newCommit); err != nil { - return fmt.Errorf("git_model.UpdateBranch %s:%s failed: %v", repo.FullName(), branch, err) - } - notify_service.PushCommits(ctx, pusher, repo, opts, commits) // Cache for big repository @@ -273,10 +275,6 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { // close all related pulls log.Error("close related pull request failed: %v", err) } - - if err := git_model.AddDeletedBranch(ctx, repo.ID, branch, pusher.ID); err != nil { - return fmt.Errorf("AddDeletedBranch %s:%s failed: %v", repo.FullName(), branch, err) - } } // Even if user delete a branch on a repository which he didn't watch, he will be watch that. @@ -292,7 +290,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } // Change repository last updated time. - if err := repo_model.UpdateRepositoryUpdatedTime(repo.ID, time.Now()); err != nil { + if err := repo_model.UpdateRepositoryUpdatedTime(ctx, repo.ID, time.Now()); err != nil { return fmt.Errorf("UpdateRepositoryUpdatedTime: %w", err) } @@ -315,20 +313,23 @@ func pushUpdateAddTags(ctx context.Context, repo *repo_model.Repository, gitRepo return nil } - lowerTags := make([]string, 0, len(tags)) - for _, tag := range tags { - lowerTags = append(lowerTags, strings.ToLower(tag)) - } - - releases, err := repo_model.GetReleasesByRepoIDAndNames(ctx, repo.ID, lowerTags) + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + RepoID: repo.ID, + TagNames: tags, + }) if err != nil { - return fmt.Errorf("GetReleasesByRepoIDAndNames: %w", err) + return fmt.Errorf("db.Find[repo_model.Release]: %w", err) } relMap := make(map[string]*repo_model.Release) for _, rel := range releases { relMap[rel.LowerTagName] = rel } + lowerTags := make([]string, 0, len(tags)) + for _, tag := range tags { + lowerTags = append(lowerTags, strings.ToLower(tag)) + } + newReleases := make([]*repo_model.Release, 0, len(lowerTags)-len(relMap)) emailToUser := make(map[string]*user_model.User) diff --git a/services/repository/repository.go b/services/repository/repository.go index 60f9568b541..d28200c0adc 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -62,7 +62,7 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod notify_service.DeleteRepository(ctx, doer, repo) } - if err := DeleteRepositoryDirectly(ctx, doer, repo.OwnerID, repo.ID); err != nil { + if err := DeleteRepositoryDirectly(ctx, doer, repo.ID); err != nil { return err } @@ -95,12 +95,12 @@ func PushCreateRepo(ctx context.Context, authUser, owner *user_model.User, repoN } // Init start repository service -func Init() error { +func Init(ctx context.Context) error { if err := repo_module.LoadRepoConfig(); err != nil { return err } - system_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repository uploads", setting.Repository.Upload.TempPath) - system_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repositories", repo_module.LocalCopyPath()) + system_model.RemoveAllWithNotice(ctx, "Clean up temporary repository uploads", setting.Repository.Upload.TempPath) + system_model.RemoveAllWithNotice(ctx, "Clean up temporary repositories", repo_module.LocalCopyPath()) if err := initPushQueue(); err != nil { return err } diff --git a/services/repository/setting.go b/services/repository/setting.go new file mode 100644 index 00000000000..b82f24271ea --- /dev/null +++ b/services/repository/setting.go @@ -0,0 +1,57 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "slices" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/log" + actions_service "code.gitea.io/gitea/services/actions" +) + +// UpdateRepositoryUnits updates a repository's units +func UpdateRepositoryUnits(ctx context.Context, repo *repo_model.Repository, units []repo_model.RepoUnit, deleteUnitTypes []unit.Type) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Delete existing settings of units before adding again + for _, u := range units { + deleteUnitTypes = append(deleteUnitTypes, u.Type) + } + + if slices.Contains(deleteUnitTypes, unit.TypeActions) { + if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks: %v", err) + } + } + + for _, u := range units { + if u.Type == unit.TypeActions { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules: %v", err) + } + break + } + } + + if _, err = db.GetEngine(ctx).Where("repo_id = ?", repo.ID).In("type", deleteUnitTypes).Delete(new(repo_model.RepoUnit)); err != nil { + return err + } + + if len(units) > 0 { + if err = db.Insert(ctx, units); err != nil { + return err + } + } + + return committer.Commit() +} diff --git a/services/repository/template.go b/services/repository/template.go index 06cf05026ff..36a680c8e20 100644 --- a/services/repository/template.go +++ b/services/repository/template.go @@ -11,7 +11,6 @@ import ( issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - repo_module "code.gitea.io/gitea/modules/repository" notify_service "code.gitea.io/gitea/services/notify" ) @@ -63,7 +62,7 @@ func GenerateProtectedBranch(ctx context.Context, templateRepo, generateRepo *re } // GenerateRepository generates a repository from a template -func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts repo_module.GenerateRepoOptions) (_ *repo_model.Repository, err error) { +func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) { if !doer.IsAdmin && !owner.CanCreateRepo() { return nil, repo_model.ErrReachLimitOfRepo{ Limit: owner.MaxRepoCreation, @@ -72,14 +71,14 @@ func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templ var generateRepo *repo_model.Repository if err = db.WithTx(ctx, func(ctx context.Context) error { - generateRepo, err = repo_module.GenerateRepository(ctx, doer, owner, templateRepo, opts) + generateRepo, err = generateRepository(ctx, doer, owner, templateRepo, opts) if err != nil { return err } // Git Content if opts.GitContent && !templateRepo.IsEmpty { - if err = repo_module.GenerateGitContent(ctx, templateRepo, generateRepo); err != nil { + if err = GenerateGitContent(ctx, templateRepo, generateRepo); err != nil { return err } } diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 2edb61816fb..83d30321882 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -6,8 +6,12 @@ package repository import ( "context" "fmt" + "os" + "strings" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" @@ -16,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/sync" + "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" ) @@ -37,7 +42,7 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep oldOwner := repo.Owner repoWorkingPool.CheckIn(fmt.Sprint(repo.ID)) - if err := models.TransferOwnership(doer, newOwner.Name, repo); err != nil { + if err := transferOwnership(ctx, doer, newOwner.Name, repo); err != nil { repoWorkingPool.CheckOut(fmt.Sprint(repo.ID)) return err } @@ -59,6 +64,278 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep return nil } +// transferOwnership transfers all corresponding repository items from old user to new one. +func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName string, repo *repo_model.Repository) (err error) { + repoRenamed := false + wikiRenamed := false + oldOwnerName := doer.Name + + defer func() { + if !repoRenamed && !wikiRenamed { + return + } + + recoverErr := recover() + if err == nil && recoverErr == nil { + return + } + + if repoRenamed { + if err := util.Rename(repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name)); err != nil { + log.Critical("Unable to move repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name, + repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name), err) + } + } + + if wikiRenamed { + if err := util.Rename(repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name)); err != nil { + log.Critical("Unable to move wiki for repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name, + repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name), err) + } + } + + if recoverErr != nil { + log.Error("Panic within TransferOwnership: %v\n%s", recoverErr, log.Stack(2)) + panic(recoverErr) + } + }() + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + sess := db.GetEngine(ctx) + + newOwner, err := user_model.GetUserByName(ctx, newOwnerName) + if err != nil { + return fmt.Errorf("get new owner '%s': %w", newOwnerName, err) + } + newOwnerName = newOwner.Name // ensure capitalisation matches + + // Check if new owner has repository with same name. + if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil { + return fmt.Errorf("IsRepositoryExist: %w", err) + } else if has { + return repo_model.ErrRepoAlreadyExist{ + Uname: newOwnerName, + Name: repo.Name, + } + } + + oldOwner := repo.Owner + oldOwnerName = oldOwner.Name + + // Note: we have to set value here to make sure recalculate accesses is based on + // new owner. + repo.OwnerID = newOwner.ID + repo.Owner = newOwner + repo.OwnerName = newOwner.Name + + // Update repository. + if _, err := sess.ID(repo.ID).Update(repo); err != nil { + return fmt.Errorf("update owner: %w", err) + } + + // Remove redundant collaborators. + collaborators, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: repo.ID}) + if err != nil { + return fmt.Errorf("GetCollaborators: %w", err) + } + + // Dummy object. + collaboration := &repo_model.Collaboration{RepoID: repo.ID} + for _, c := range collaborators { + if c.IsGhost() { + collaboration.ID = c.Collaboration.ID + if _, err := sess.Delete(collaboration); err != nil { + return fmt.Errorf("remove collaborator '%d': %w", c.ID, err) + } + collaboration.ID = 0 + } + + if c.ID != newOwner.ID { + isMember, err := organization.IsOrganizationMember(ctx, newOwner.ID, c.ID) + if err != nil { + return fmt.Errorf("IsOrgMember: %w", err) + } else if !isMember { + continue + } + } + collaboration.UserID = c.ID + if _, err := sess.Delete(collaboration); err != nil { + return fmt.Errorf("remove collaborator '%d': %w", c.ID, err) + } + collaboration.UserID = 0 + } + + // Remove old team-repository relations. + if oldOwner.IsOrganization() { + if err := organization.RemoveOrgRepo(ctx, oldOwner.ID, repo.ID); err != nil { + return fmt.Errorf("removeOrgRepo: %w", err) + } + } + + if newOwner.IsOrganization() { + teams, err := organization.FindOrgTeams(ctx, newOwner.ID) + if err != nil { + return fmt.Errorf("LoadTeams: %w", err) + } + for _, t := range teams { + if t.IncludesAllRepositories { + if err := models.AddRepository(ctx, t, repo); err != nil { + return fmt.Errorf("AddRepository: %w", err) + } + } + } + } else if err := access_model.RecalculateAccesses(ctx, repo); err != nil { + // Organization called this in addRepository method. + return fmt.Errorf("recalculateAccesses: %w", err) + } + + // Update repository count. + if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { + return fmt.Errorf("increase new owner repository count: %w", err) + } else if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil { + return fmt.Errorf("decrease old owner repository count: %w", err) + } + + if err := repo_model.WatchRepo(ctx, doer, repo, true); err != nil { + return fmt.Errorf("watchRepo: %w", err) + } + + // Remove watch for organization. + if oldOwner.IsOrganization() { + if err := repo_model.WatchRepo(ctx, oldOwner, repo, false); err != nil { + return fmt.Errorf("watchRepo [false]: %w", err) + } + } + + // Delete labels that belong to the old organization and comments that added these labels + if oldOwner.IsOrganization() { + if _, err := sess.Exec(`DELETE FROM issue_label WHERE issue_label.id IN ( + SELECT il_too.id FROM ( + SELECT il_too_too.id + FROM issue_label AS il_too_too + INNER JOIN label ON il_too_too.label_id = label.id + INNER JOIN issue on issue.id = il_too_too.issue_id + WHERE + issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?)) + ) AS il_too )`, repo.ID, newOwner.ID); err != nil { + return fmt.Errorf("Unable to remove old org labels: %w", err) + } + + if _, err := sess.Exec(`DELETE FROM comment WHERE comment.id IN ( + SELECT il_too.id FROM ( + SELECT com.id + FROM comment AS com + INNER JOIN label ON com.label_id = label.id + INNER JOIN issue ON issue.id = com.issue_id + WHERE + com.type = ? AND issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?)) + ) AS il_too)`, issues_model.CommentTypeLabel, repo.ID, newOwner.ID); err != nil { + return fmt.Errorf("Unable to remove old org label comments: %w", err) + } + } + + // Rename remote repository to new path and delete local copy. + dir := user_model.UserPath(newOwner.Name) + + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("Failed to create dir %s: %w", dir, err) + } + + if err := util.Rename(repo_model.RepoPath(oldOwner.Name, repo.Name), repo_model.RepoPath(newOwner.Name, repo.Name)); err != nil { + return fmt.Errorf("rename repository directory: %w", err) + } + repoRenamed = true + + // Rename remote wiki repository to new path and delete local copy. + wikiPath := repo_model.WikiPath(oldOwner.Name, repo.Name) + + if isExist, err := util.IsExist(wikiPath); err != nil { + log.Error("Unable to check if %s exists. Error: %v", wikiPath, err) + return err + } else if isExist { + if err := util.Rename(wikiPath, repo_model.WikiPath(newOwner.Name, repo.Name)); err != nil { + return fmt.Errorf("rename repository wiki: %w", err) + } + wikiRenamed = true + } + + if err := models.DeleteRepositoryTransfer(ctx, repo.ID); err != nil { + return fmt.Errorf("deleteRepositoryTransfer: %w", err) + } + repo.Status = repo_model.RepositoryReady + if err := repo_model.UpdateRepositoryCols(ctx, repo, "status"); err != nil { + return err + } + + // If there was previously a redirect at this location, remove it. + if err := repo_model.DeleteRedirect(ctx, newOwner.ID, repo.Name); err != nil { + return fmt.Errorf("delete repo redirect: %w", err) + } + + if err := repo_model.NewRedirect(ctx, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { + return fmt.Errorf("repo_model.NewRedirect: %w", err) + } + + return committer.Commit() +} + +// changeRepositoryName changes all corresponding setting from old repository name to new one. +func changeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newRepoName string) (err error) { + oldRepoName := repo.Name + newRepoName = strings.ToLower(newRepoName) + if err = repo_model.IsUsableRepoName(newRepoName); err != nil { + return err + } + + if err := repo.LoadOwner(ctx); err != nil { + return err + } + + has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName) + if err != nil { + return fmt.Errorf("IsRepositoryExist: %w", err) + } else if has { + return repo_model.ErrRepoAlreadyExist{ + Uname: repo.Owner.Name, + Name: newRepoName, + } + } + + newRepoPath := repo_model.RepoPath(repo.Owner.Name, newRepoName) + if err = util.Rename(repo.RepoPath(), newRepoPath); err != nil { + return fmt.Errorf("rename repository directory: %w", err) + } + + wikiPath := repo.WikiPath() + isExist, err := util.IsExist(wikiPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", wikiPath, err) + return err + } + if isExist { + if err = util.Rename(wikiPath, repo_model.WikiPath(repo.Owner.Name, newRepoName)); err != nil { + return fmt.Errorf("rename repository wiki: %w", err) + } + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err := repo_model.NewRedirect(ctx, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil { + return err + } + + return committer.Commit() +} + // ChangeRepositoryName changes all corresponding setting from old repository name to new one. func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newRepoName string) error { log.Trace("ChangeRepositoryName: %s/%s -> %s", doer.Name, repo.Name, newRepoName) @@ -70,7 +347,7 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo // local copy's origin accordingly. repoWorkingPool.CheckIn(fmt.Sprint(repo.ID)) - if err := repo_model.ChangeRepositoryName(doer, repo, newRepoName); err != nil { + if err := changeRepositoryName(ctx, doer, repo, newRepoName); err != nil { repoWorkingPool.CheckOut(fmt.Sprint(repo.ID)) return err } @@ -94,6 +371,10 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use return TransferOwnership(ctx, doer, newOwner, repo, teams) } + if user_model.IsUserBlockedBy(ctx, doer, newOwner.ID) { + return user_model.ErrBlockedUser + } + // If new owner is an org and user can create repos he can transfer directly too if newOwner.IsOrganization() { allowed, err := organization.CanCreateOrgRepo(ctx, newOwner.ID, doer.ID) @@ -130,3 +411,24 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use return nil } + +// CancelRepositoryTransfer marks the repository as ready and remove pending transfer entry, +// thus cancel the transfer process. +func CancelRepositoryTransfer(ctx context.Context, repo *repo_model.Repository) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + repo.Status = repo_model.RepositoryReady + if err := repo_model.UpdateRepositoryCols(ctx, repo, "status"); err != nil { + return err + } + + if err := models.DeleteRepositoryTransfer(ctx, repo.ID); err != nil { + return err + } + + return committer.Commit() +} diff --git a/services/repository/transfer_test.go b/services/repository/transfer_test.go index d55c76ea47f..c3f03d6638f 100644 --- a/services/repository/transfer_test.go +++ b/services/repository/transfer_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "code.gitea.io/gitea/models" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" @@ -78,3 +79,45 @@ func TestStartRepositoryTransferSetPermission(t *testing.T) { unittest.CheckConsistencyFor(t, &repo_model.Repository{}, &user_model.User{}, &organization.Team{}) } + +func TestRepositoryTransfer(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + transfer, err := models.GetPendingRepositoryTransfer(db.DefaultContext, repo) + assert.NoError(t, err) + assert.NotNil(t, transfer) + + // Cancel transfer + assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo)) + + transfer, err = models.GetPendingRepositoryTransfer(db.DefaultContext, repo) + assert.Error(t, err) + assert.Nil(t, transfer) + assert.True(t, models.IsErrNoPendingTransfer(err)) + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, user2, repo.ID, nil)) + + transfer, err = models.GetPendingRepositoryTransfer(db.DefaultContext, repo) + assert.Nil(t, err) + assert.NoError(t, transfer.LoadAttributes(db.DefaultContext)) + assert.Equal(t, "user2", transfer.Recipient.Name) + + org6 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Only transfer can be started at any given time + err = models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, org6, repo.ID, nil) + assert.Error(t, err) + assert.True(t, models.IsErrRepoTransferInProgress(err)) + + // Unknown user + err = models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, &user_model.User{ID: 1000, LowerName: "user1000"}, repo.ID, nil) + assert.Error(t, err) + + // Cancel transfer + assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo)) +} diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go index 1c4772d6bf9..031c474dd72 100644 --- a/services/secrets/secrets.go +++ b/services/secrets/secrets.go @@ -15,7 +15,7 @@ func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data return nil, false, err } - s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{ + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ OwnerID: ownerID, RepoID: repoID, Name: name, @@ -40,7 +40,7 @@ func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data } func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error { - s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{ + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ OwnerID: ownerID, RepoID: repoID, SecretID: secretID, @@ -60,7 +60,7 @@ func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) return err } - s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{ + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ OwnerID: ownerID, RepoID: repoID, Name: name, @@ -76,7 +76,7 @@ func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) } func deleteSecret(ctx context.Context, s *secret_model.Secret) error { - if _, err := db.DeleteByID(ctx, s.ID, s); err != nil { + if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil { return err } return nil diff --git a/services/task/migrate.go b/services/task/migrate.go index ebf179045e0..9cef77a6c89 100644 --- a/services/task/migrate.go +++ b/services/task/migrate.go @@ -4,6 +4,7 @@ package task import ( + "context" "errors" "fmt" "strings" @@ -40,17 +41,16 @@ func handleCreateError(owner *user_model.User, err error) error { } } -func runMigrateTask(t *admin_model.Task) (err error) { - defer func() { +func runMigrateTask(ctx context.Context, t *admin_model.Task) (err error) { + defer func(ctx context.Context) { if e := recover(); e != nil { err = fmt.Errorf("PANIC whilst trying to do migrate task: %v", e) log.Critical("PANIC during runMigrateTask[%d] by DoerID[%d] to RepoID[%d] for OwnerID[%d]: %v\nStacktrace: %v", t.ID, t.DoerID, t.RepoID, t.OwnerID, e, log.Stack(2)) } - if err == nil { - err = admin_model.FinishMigrateTask(t) + err = admin_model.FinishMigrateTask(ctx, t) if err == nil { - notify_service.MigrateRepository(db.DefaultContext, t.Doer, t.Owner, t.Repo) + notify_service.MigrateRepository(ctx, t.Doer, t.Owner, t.Repo) return } @@ -62,15 +62,14 @@ func runMigrateTask(t *admin_model.Task) (err error) { t.EndTime = timeutil.TimeStampNow() t.Status = structs.TaskStatusFailed t.Message = err.Error() - - if err := t.UpdateCols("status", "message", "end_time"); err != nil { + if err := t.UpdateCols(ctx, "status", "message", "end_time"); err != nil { log.Error("Task UpdateCols failed: %v", err) } // then, do not delete the repository, otherwise the users won't be able to see the last error - }() + }(graceful.GetManager().ShutdownContext()) // even if the parent ctx is canceled, this defer-function still needs to update the task record in database - if err = t.LoadRepo(); err != nil { + if err = t.LoadRepo(ctx); err != nil { return err } @@ -79,10 +78,10 @@ func runMigrateTask(t *admin_model.Task) (err error) { return nil } - if err = t.LoadDoer(); err != nil { + if err = t.LoadDoer(ctx); err != nil { return err } - if err = t.LoadOwner(); err != nil { + if err = t.LoadOwner(ctx); err != nil { return err } @@ -100,7 +99,7 @@ func runMigrateTask(t *admin_model.Task) (err error) { t.StartTime = timeutil.TimeStampNow() t.Status = structs.TaskStatusRunning - if err = t.UpdateCols("start_time", "status"); err != nil { + if err = t.UpdateCols(ctx, "start_time", "status"); err != nil { return err } @@ -112,7 +111,7 @@ func runMigrateTask(t *admin_model.Task) (err error) { case <-ctx.Done(): return } - task, _ := admin_model.GetMigratingTask(t.RepoID) + task, _ := admin_model.GetMigratingTask(ctx, t.RepoID) if task != nil && task.Status != structs.TaskStatusRunning { log.Debug("MigrateTask[%d] by DoerID[%d] to RepoID[%d] for OwnerID[%d] is canceled due to status is not 'running'", t.ID, t.DoerID, t.RepoID, t.OwnerID) cancel() @@ -128,7 +127,7 @@ func runMigrateTask(t *admin_model.Task) (err error) { } bs, _ := json.Marshal(message) t.Message = string(bs) - _ = t.UpdateCols("message") + _ = t.UpdateCols(ctx, "message") }) if err == nil { diff --git a/services/task/task.go b/services/task/task.go index 3a40faef90a..e15cab7b3c9 100644 --- a/services/task/task.go +++ b/services/task/task.go @@ -4,6 +4,7 @@ package task import ( + "context" "fmt" admin_model "code.gitea.io/gitea/models/admin" @@ -27,10 +28,10 @@ import ( var taskQueue *queue.WorkerPoolQueue[*admin_model.Task] // Run a task -func Run(t *admin_model.Task) error { +func Run(ctx context.Context, t *admin_model.Task) error { switch t.Type { case structs.TaskTypeMigrateRepo: - return runMigrateTask(t) + return runMigrateTask(ctx, t) default: return fmt.Errorf("Unknown task type: %d", t.Type) } @@ -48,7 +49,7 @@ func Init() error { func handler(items ...*admin_model.Task) []*admin_model.Task { for _, task := range items { - if err := Run(task); err != nil { + if err := Run(db.DefaultContext, task); err != nil { log.Error("Run task failed: %v", err) } } @@ -56,8 +57,8 @@ func handler(items ...*admin_model.Task) []*admin_model.Task { } // MigrateRepository add migration repository to task -func MigrateRepository(doer, u *user_model.User, opts base.MigrateOptions) error { - task, err := CreateMigrateTask(doer, u, opts) +func MigrateRepository(ctx context.Context, doer, u *user_model.User, opts base.MigrateOptions) error { + task, err := CreateMigrateTask(ctx, doer, u, opts) if err != nil { return err } @@ -66,7 +67,7 @@ func MigrateRepository(doer, u *user_model.User, opts base.MigrateOptions) error } // CreateMigrateTask creates a migrate task -func CreateMigrateTask(doer, u *user_model.User, opts base.MigrateOptions) (*admin_model.Task, error) { +func CreateMigrateTask(ctx context.Context, doer, u *user_model.User, opts base.MigrateOptions) (*admin_model.Task, error) { // encrypt credentials for persistence var err error opts.CloneAddrEncrypted, err = secret.EncryptSecret(setting.SecretKey, opts.CloneAddr) @@ -97,11 +98,11 @@ func CreateMigrateTask(doer, u *user_model.User, opts base.MigrateOptions) (*adm PayloadContent: string(bs), } - if err := admin_model.CreateTask(task); err != nil { + if err := admin_model.CreateTask(ctx, task); err != nil { return nil, err } - repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, doer, u, repo_service.CreateRepoOptions{ + repo, err := repo_service.CreateRepositoryDirectly(ctx, doer, u, repo_service.CreateRepoOptions{ Name: opts.RepoName, Description: opts.Description, OriginalURL: opts.OriginalURL, @@ -113,7 +114,7 @@ func CreateMigrateTask(doer, u *user_model.User, opts base.MigrateOptions) (*adm if err != nil { task.EndTime = timeutil.TimeStampNow() task.Status = structs.TaskStatusFailed - err2 := task.UpdateCols("end_time", "status") + err2 := task.UpdateCols(ctx, "end_time", "status") if err2 != nil { log.Error("UpdateCols Failed: %v", err2.Error()) } @@ -121,7 +122,7 @@ func CreateMigrateTask(doer, u *user_model.User, opts base.MigrateOptions) (*adm } task.RepoID = repo.ID - if err = task.UpdateCols("repo_id"); err != nil { + if err = task.UpdateCols(ctx, "repo_id"); err != nil { return nil, err } @@ -129,8 +130,8 @@ func CreateMigrateTask(doer, u *user_model.User, opts base.MigrateOptions) (*adm } // RetryMigrateTask retry a migrate task -func RetryMigrateTask(repoID int64) error { - migratingTask, err := admin_model.GetMigratingTask(repoID) +func RetryMigrateTask(ctx context.Context, repoID int64) error { + migratingTask, err := admin_model.GetMigratingTask(ctx, repoID) if err != nil { log.Error("GetMigratingTask: %v", err) return err @@ -144,7 +145,7 @@ func RetryMigrateTask(repoID int64) error { // Reset task status and messages migratingTask.Status = structs.TaskStatusQueued migratingTask.Message = "" - if err = migratingTask.UpdateCols("status", "message"); err != nil { + if err = migratingTask.UpdateCols(ctx, "status", "message"); err != nil { log.Error("task.UpdateCols failed: %v", err) return err } diff --git a/services/uinotification/notify.go b/services/uinotification/notify.go index 9cf4fe73d78..be5f7019a2e 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -52,7 +52,7 @@ func NewNotifier() notify_service.Notifier { func handler(items ...issueNotificationOpts) []issueNotificationOpts { for _, opts := range items { - if err := activities_model.CreateOrUpdateIssueNotifications(opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil { + if err := activities_model.CreateOrUpdateIssueNotifications(db.DefaultContext, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil { log.Error("Was unable to create issue notification: %v", err) } } @@ -114,7 +114,7 @@ func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_ log.Error("issue.LoadPullRequest: %v", err) return } - if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress() { + if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) { _ = ns.issueQueue.Push(issueNotificationOpts{ IssueID: issue.ID, NotificationAuthorID: doer.ID, diff --git a/services/user/avatar.go b/services/user/avatar.go index 26c100abdbe..2d6c3faf9a5 100644 --- a/services/user/avatar.go +++ b/services/user/avatar.go @@ -4,6 +4,7 @@ package user import ( + "context" "fmt" "io" @@ -15,13 +16,13 @@ import ( ) // UploadAvatar saves custom avatar for user. -func UploadAvatar(u *user_model.User, data []byte) error { +func UploadAvatar(ctx context.Context, u *user_model.User, data []byte) error { avatarData, err := avatar.ProcessAvatarImage(data) if err != nil { return err } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } @@ -44,7 +45,7 @@ func UploadAvatar(u *user_model.User, data []byte) error { } // DeleteAvatar deletes the user's custom avatar. -func DeleteAvatar(u *user_model.User) error { +func DeleteAvatar(ctx context.Context, u *user_model.User) error { aPath := u.CustomAvatarRelativePath() log.Trace("DeleteAvatar[%d]: %s", u.ID, aPath) if len(u.Avatar) > 0 { @@ -55,8 +56,8 @@ func DeleteAvatar(u *user_model.User) error { u.UseCustomAvatar = false u.Avatar = "" - if _, err := db.GetEngine(db.DefaultContext).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { - return fmt.Errorf("UpdateUser: %w", err) + if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { + return fmt.Errorf("DeleteAvatar: %w", err) } return nil } diff --git a/services/user/block.go b/services/user/block.go new file mode 100644 index 00000000000..0b3b618aae6 --- /dev/null +++ b/services/user/block.go @@ -0,0 +1,308 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + org_model "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + repo_service "code.gitea.io/gitea/services/repository" +) + +func CanBlockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { + if blocker.ID == blockee.ID { + return false + } + if doer.ID == blockee.ID { + return false + } + + if blockee.IsOrganization() { + return false + } + + if user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { + return false + } + + if blocker.IsOrganization() { + org := org_model.OrgFromUser(blocker) + if isMember, _ := org.IsOrgMember(ctx, blockee.ID); isMember { + return false + } + if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { + return false + } + } else if !doer.IsAdmin && doer.ID != blocker.ID { + return false + } + + return true +} + +func CanUnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { + if doer.ID == blockee.ID { + return false + } + + if !user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { + return false + } + + if blocker.IsOrganization() { + org := org_model.OrgFromUser(blocker) + if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { + return false + } + } else if !doer.IsAdmin && doer.ID != blocker.ID { + return false + } + + return true +} + +func BlockUser(ctx context.Context, doer, blocker, blockee *user_model.User, note string) error { + if blockee.IsOrganization() { + return user_model.ErrBlockOrganization + } + + if !CanBlockUser(ctx, doer, blocker, blockee) { + return user_model.ErrCanNotBlock + } + + return db.WithTx(ctx, func(ctx context.Context) error { + // unfollow each other + if err := user_model.UnfollowUser(ctx, blocker.ID, blockee.ID); err != nil { + return err + } + if err := user_model.UnfollowUser(ctx, blockee.ID, blocker.ID); err != nil { + return err + } + + // unstar each other + if err := unstarRepos(ctx, blocker, blockee); err != nil { + return err + } + if err := unstarRepos(ctx, blockee, blocker); err != nil { + return err + } + + // unwatch each others repositories + if err := unwatchRepos(ctx, blocker, blockee); err != nil { + return err + } + if err := unwatchRepos(ctx, blockee, blocker); err != nil { + return err + } + + // unassign each other from issues + if err := unassignIssues(ctx, blocker, blockee); err != nil { + return err + } + if err := unassignIssues(ctx, blockee, blocker); err != nil { + return err + } + + // remove each other from repository collaborations + if err := removeCollaborations(ctx, blocker, blockee); err != nil { + return err + } + if err := removeCollaborations(ctx, blockee, blocker); err != nil { + return err + } + + // cancel each other repository transfers + if err := cancelRepositoryTransfers(ctx, blocker, blockee); err != nil { + return err + } + if err := cancelRepositoryTransfers(ctx, blockee, blocker); err != nil { + return err + } + + return db.Insert(ctx, &user_model.Blocking{ + BlockerID: blocker.ID, + BlockeeID: blockee.ID, + Note: note, + }) + }) +} + +func unstarRepos(ctx context.Context, starrer, repoOwner *user_model.User) error { + opts := &repo_model.StarredReposOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + StarrerID: starrer.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + repos, err := repo_model.GetStarredRepos(ctx, opts) + if err != nil { + return err + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + if err := repo_model.StarRepo(ctx, starrer, repo, false); err != nil { + return err + } + } + + opts.Page++ + } +} + +func unwatchRepos(ctx context.Context, watcher, repoOwner *user_model.User) error { + opts := &repo_model.WatchedReposOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + WatcherID: watcher.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + repos, _, err := repo_model.GetWatchedRepos(ctx, opts) + if err != nil { + return err + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + if err := repo_model.WatchRepo(ctx, watcher, repo, false); err != nil { + return err + } + } + + opts.Page++ + } +} + +func cancelRepositoryTransfers(ctx context.Context, sender, recipient *user_model.User) error { + transfers, err := models.GetPendingRepositoryTransfers(ctx, &models.PendingRepositoryTransferOptions{ + SenderID: sender.ID, + RecipientID: recipient.ID, + }) + if err != nil { + return err + } + + for _, transfer := range transfers { + repo, err := repo_model.GetRepositoryByID(ctx, transfer.RepoID) + if err != nil { + return err + } + + if err := repo_service.CancelRepositoryTransfer(ctx, repo); err != nil { + return err + } + } + + return nil +} + +func unassignIssues(ctx context.Context, assignee, repoOwner *user_model.User) error { + opts := &issues_model.AssignedIssuesOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + AssigneeID: assignee.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + issues, _, err := issues_model.GetAssignedIssues(ctx, opts) + if err != nil { + return err + } + + if len(issues) == 0 { + return nil + } + + for _, issue := range issues { + if err := issue.LoadAssignees(ctx); err != nil { + return err + } + + if _, _, err := issues_model.ToggleIssueAssignee(ctx, issue, assignee, assignee.ID); err != nil { + return err + } + } + + opts.Page++ + } +} + +func removeCollaborations(ctx context.Context, repoOwner, collaborator *user_model.User) error { + opts := &repo_model.FindCollaborationOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + CollaboratorID: collaborator.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + collaborations, _, err := repo_model.GetCollaborators(ctx, opts) + if err != nil { + return err + } + + if len(collaborations) == 0 { + return nil + } + + for _, collaboration := range collaborations { + repo, err := repo_model.GetRepositoryByID(ctx, collaboration.Collaboration.RepoID) + if err != nil { + return err + } + + if err := repo_service.DeleteCollaboration(ctx, repo, collaborator); err != nil { + return err + } + } + + opts.Page++ + } +} + +func UnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) error { + if blockee.IsOrganization() { + return user_model.ErrBlockOrganization + } + + if !CanUnblockUser(ctx, doer, blocker, blockee) { + return user_model.ErrCanNotUnblock + } + + return db.WithTx(ctx, func(ctx context.Context) error { + block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + return err + } + if block != nil { + _, err = db.DeleteByID[user_model.Blocking](ctx, block.ID) + return err + } + return nil + }) +} diff --git a/services/user/block_test.go b/services/user/block_test.go new file mode 100644 index 00000000000..aec3e03cf37 --- /dev/null +++ b/services/user/block_test.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestCanBlockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + // Doer can't self block + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user1)) + // Blocker can't be blockee + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user2)) + // Can't block already blocked user + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user29)) + // Blockee can't be an organization + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, org3)) + // Doer must be blocker or admin + assert.False(t, CanBlockUser(db.DefaultContext, user2, user4, user29)) + // Organization can't block a member + assert.False(t, CanBlockUser(db.DefaultContext, user1, org3, user4)) + // Doer must be organization owner or admin if blocker is an organization + assert.False(t, CanBlockUser(db.DefaultContext, user4, org3, user2)) + + assert.True(t, CanBlockUser(db.DefaultContext, user1, user2, user4)) + assert.True(t, CanBlockUser(db.DefaultContext, user2, user2, user4)) + assert.True(t, CanBlockUser(db.DefaultContext, user2, org3, user29)) +} + +func TestCanUnblockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + // Doer can't self unblock + assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user1)) + // Can't unblock not blocked user + assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user28)) + // Doer must be blocker or admin + assert.False(t, CanUnblockUser(db.DefaultContext, user28, user2, user29)) + // Doer must be organization owner or admin if blocker is an organization + assert.False(t, CanUnblockUser(db.DefaultContext, user2, org17, user28)) + + assert.True(t, CanUnblockUser(db.DefaultContext, user1, user2, user29)) + assert.True(t, CanUnblockUser(db.DefaultContext, user2, user2, user29)) + assert.True(t, CanUnblockUser(db.DefaultContext, user1, org17, user28)) +} diff --git a/services/user/delete.go b/services/user/delete.go index 01e3c37b39f..212cb83e031 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -10,6 +10,7 @@ import ( _ "image/jpeg" // Needed for jpeg support + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" asymkey_model "code.gitea.io/gitea/models/asymkey" auth_model "code.gitea.io/gitea/models/auth" @@ -90,6 +91,9 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &pull_model.AutoMerge{DoerID: u.ID}, &pull_model.ReviewState{UserID: u.ID}, &user_model.Redirect{RedirectUserID: u.ID}, + &actions_model.ActionRunner{OwnerID: u.ID}, + &user_model.Blocking{BlockerID: u.ID}, + &user_model.Blocking{BlockeeID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } @@ -157,7 +161,9 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) // ***** END: PublicKey ***** // ***** START: GPGPublicKey ***** - keys, err := asymkey_model.ListGPGKeys(ctx, u.ID, db.ListOptions{}) + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + OwnerID: u.ID, + }) if err != nil { return fmt.Errorf("ListGPGKeys: %w", err) } @@ -183,7 +189,11 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) } // ***** END: ExternalLoginUser ***** - if _, err = db.DeleteByID(ctx, u.ID, new(user_model.User)); err != nil { + if err := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil { + return fmt.Errorf("DeleteAuthTokensByUserID: %w", err) + } + + if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil { return fmt.Errorf("delete: %w", err) } diff --git a/services/user/email.go b/services/user/email.go new file mode 100644 index 00000000000..5c0de708e9a --- /dev/null +++ b/services/user/email.go @@ -0,0 +1,167 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "errors" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address +func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { + if strings.EqualFold(u.Email, emailStr) { + return nil + } + + if err := user_model.ValidateEmailForAdmin(emailStr); err != nil { + return err + } + + // Check if address exists already + email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return err + } + if email != nil && email.UID != u.ID { + return user_model.ErrEmailAlreadyUsed{Email: emailStr} + } + + // Update old primary address + primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID) + if err != nil { + return err + } + + primary.IsPrimary = false + if err := user_model.UpdateEmailAddress(ctx, primary); err != nil { + return err + } + + // Insert new or update existing address + if email != nil { + email.IsPrimary = true + email.IsActivated = true + if err := user_model.UpdateEmailAddress(ctx, email); err != nil { + return err + } + } else { + email = &user_model.EmailAddress{ + UID: u.ID, + Email: emailStr, + IsActivated: true, + IsPrimary: true, + } + if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { + return err + } + } + + u.Email = emailStr + + return user_model.UpdateUserCols(ctx, u, "email") +} + +func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { + if strings.EqualFold(u.Email, emailStr) { + return nil + } + + if err := user_model.ValidateEmail(emailStr); err != nil { + return err + } + + if !u.IsOrganization() { + // Check if address exists already + email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return err + } + if email != nil { + if email.IsPrimary && email.UID == u.ID { + return nil + } + return user_model.ErrEmailAlreadyUsed{Email: emailStr} + } + + // Remove old primary address + primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID) + if err != nil { + return err + } + if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil { + return err + } + + // Insert new primary address + email = &user_model.EmailAddress{ + UID: u.ID, + Email: emailStr, + IsActivated: true, + IsPrimary: true, + } + if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { + return err + } + } + + u.Email = emailStr + + return user_model.UpdateUserCols(ctx, u, "email") +} + +func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error { + for _, emailStr := range emails { + if err := user_model.ValidateEmail(emailStr); err != nil { + return err + } + + // Check if address exists already + email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return err + } + if email != nil { + return user_model.ErrEmailAlreadyUsed{Email: emailStr} + } + + // Insert new address + email = &user_model.EmailAddress{ + UID: u.ID, + Email: emailStr, + IsActivated: !setting.Service.RegisterEmailConfirm, + IsPrimary: false, + } + if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { + return err + } + } + + return nil +} + +func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error { + for _, emailStr := range emails { + // Check if address exists + email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID) + if err != nil { + return err + } + if email.IsPrimary { + return user_model.ErrPrimaryEmailCannotDelete{Email: emailStr} + } + + // Remove address + if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil { + return err + } + } + + return nil +} diff --git a/services/user/email_test.go b/services/user/email_test.go new file mode 100644 index 00000000000..b40f86b6a68 --- /dev/null +++ b/services/user/email_test.go @@ -0,0 +1,143 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + organization_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/gobwas/glob" + "github.com/stretchr/testify/assert" +) + +func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27}) + + emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Len(t, emails, 1) + + primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.NotEqual(t, "new-primary@example.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary@example.com")) + + primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Equal(t, "new-primary@example.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Len(t, emails, 2) + + setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")} + defer func() { + setting.Service.EmailDomainAllowList = []glob.Glob{} + }() + + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary2@example2.com")) + + primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Equal(t, "new-primary2@example2.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "user27@example.com")) + + primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Equal(t, "user27@example.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Len(t, emails, 3) +} + +func TestReplacePrimaryEmailAddress(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("User", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13}) + + emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Len(t, emails, 1) + + primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.NotEqual(t, "primary-13@example.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com")) + + primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Equal(t, "primary-13@example.com", primary.Email) + assert.Equal(t, user.Email, primary.Email) + + emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID) + assert.NoError(t, err) + assert.Len(t, emails, 1) + + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com")) + }) + + t.Run("Organization", func(t *testing.T) { + org := unittest.AssertExistsAndLoadBean(t, &organization_model.Organization{ID: 3}) + + assert.Equal(t, "org3@example.com", org.Email) + + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, org.AsUser(), "primary-org@example.com")) + + assert.Equal(t, "primary-org@example.com", org.Email) + }) +} + +func TestAddEmailAddresses(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.Error(t, AddEmailAddresses(db.DefaultContext, user, []string{" invalid email "})) + + emails := []string{"user1234@example.com", "user5678@example.com"} + + assert.NoError(t, AddEmailAddresses(db.DefaultContext, user, emails)) + + err := AddEmailAddresses(db.DefaultContext, user, emails) + assert.Error(t, err) + assert.True(t, user_model.IsErrEmailAlreadyUsed(err)) +} + +func TestDeleteEmailAddresses(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + emails := []string{"user2-2@example.com"} + + err := DeleteEmailAddresses(db.DefaultContext, user, emails) + assert.NoError(t, err) + + err = DeleteEmailAddresses(db.DefaultContext, user, emails) + assert.Error(t, err) + assert.True(t, user_model.IsErrEmailAddressNotExist(err)) + + emails = []string{"user2@example.com"} + + err = DeleteEmailAddresses(db.DefaultContext, user, emails) + assert.Error(t, err) + assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err)) +} diff --git a/services/user/update.go b/services/user/update.go new file mode 100644 index 00000000000..cbaf90053a2 --- /dev/null +++ b/services/user/update.go @@ -0,0 +1,222 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + password_module "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" +) + +type UpdateOptions struct { + KeepEmailPrivate optional.Option[bool] + FullName optional.Option[string] + Website optional.Option[string] + Location optional.Option[string] + Description optional.Option[string] + AllowGitHook optional.Option[bool] + AllowImportLocal optional.Option[bool] + MaxRepoCreation optional.Option[int] + IsRestricted optional.Option[bool] + Visibility optional.Option[structs.VisibleType] + KeepActivityPrivate optional.Option[bool] + Language optional.Option[string] + Theme optional.Option[string] + DiffViewStyle optional.Option[string] + AllowCreateOrganization optional.Option[bool] + IsActive optional.Option[bool] + IsAdmin optional.Option[bool] + EmailNotificationsPreference optional.Option[string] + SetLastLogin bool + RepoAdminChangeTeamAccess optional.Option[bool] +} + +func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { + cols := make([]string, 0, 20) + + if opts.KeepEmailPrivate.Has() { + u.KeepEmailPrivate = opts.KeepEmailPrivate.Value() + + cols = append(cols, "keep_email_private") + } + + if opts.FullName.Has() { + u.FullName = opts.FullName.Value() + + cols = append(cols, "full_name") + } + if opts.Website.Has() { + u.Website = opts.Website.Value() + + cols = append(cols, "website") + } + if opts.Location.Has() { + u.Location = opts.Location.Value() + + cols = append(cols, "location") + } + if opts.Description.Has() { + u.Description = opts.Description.Value() + + cols = append(cols, "description") + } + if opts.Language.Has() { + u.Language = opts.Language.Value() + + cols = append(cols, "language") + } + if opts.Theme.Has() { + u.Theme = opts.Theme.Value() + + cols = append(cols, "theme") + } + if opts.DiffViewStyle.Has() { + u.DiffViewStyle = opts.DiffViewStyle.Value() + + cols = append(cols, "diff_view_style") + } + + if opts.AllowGitHook.Has() { + u.AllowGitHook = opts.AllowGitHook.Value() + + cols = append(cols, "allow_git_hook") + } + if opts.AllowImportLocal.Has() { + u.AllowImportLocal = opts.AllowImportLocal.Value() + + cols = append(cols, "allow_import_local") + } + + if opts.MaxRepoCreation.Has() { + u.MaxRepoCreation = opts.MaxRepoCreation.Value() + + cols = append(cols, "max_repo_creation") + } + + if opts.IsActive.Has() { + u.IsActive = opts.IsActive.Value() + + cols = append(cols, "is_active") + } + if opts.IsRestricted.Has() { + u.IsRestricted = opts.IsRestricted.Value() + + cols = append(cols, "is_restricted") + } + if opts.IsAdmin.Has() { + if !opts.IsAdmin.Value() && user_model.IsLastAdminUser(ctx, u) { + return models.ErrDeleteLastAdminUser{UID: u.ID} + } + + u.IsAdmin = opts.IsAdmin.Value() + + cols = append(cols, "is_admin") + } + + if opts.Visibility.Has() { + if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) { + return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String()) + } + u.Visibility = opts.Visibility.Value() + + cols = append(cols, "visibility") + } + if opts.KeepActivityPrivate.Has() { + u.KeepActivityPrivate = opts.KeepActivityPrivate.Value() + + cols = append(cols, "keep_activity_private") + } + + if opts.AllowCreateOrganization.Has() { + u.AllowCreateOrganization = opts.AllowCreateOrganization.Value() + + cols = append(cols, "allow_create_organization") + } + if opts.RepoAdminChangeTeamAccess.Has() { + u.RepoAdminChangeTeamAccess = opts.RepoAdminChangeTeamAccess.Value() + + cols = append(cols, "repo_admin_change_team_access") + } + + if opts.EmailNotificationsPreference.Has() { + u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value() + + cols = append(cols, "email_notifications_preference") + } + + if opts.SetLastLogin { + u.SetLastLogin() + + cols = append(cols, "last_login_unix") + } + + return user_model.UpdateUserCols(ctx, u, cols...) +} + +type UpdateAuthOptions struct { + LoginSource optional.Option[int64] + LoginName optional.Option[string] + Password optional.Option[string] + MustChangePassword optional.Option[bool] + ProhibitLogin optional.Option[bool] +} + +func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error { + if opts.LoginSource.Has() { + source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value()) + if err != nil { + return err + } + + u.LoginType = source.Type + u.LoginSource = source.ID + } + if opts.LoginName.Has() { + u.LoginName = opts.LoginName.Value() + } + + deleteAuthTokens := false + if opts.Password.Has() && (u.IsLocal() || u.IsOAuth2()) { + password := opts.Password.Value() + + if len(password) < setting.MinPasswordLength { + return password_module.ErrMinLength + } + if !password_module.IsComplexEnough(password) { + return password_module.ErrComplexity + } + if err := password_module.IsPwned(ctx, password); err != nil { + return err + } + + if err := u.SetPassword(password); err != nil { + return err + } + + deleteAuthTokens = true + } + + if opts.MustChangePassword.Has() { + u.MustChangePassword = opts.MustChangePassword.Value() + } + if opts.ProhibitLogin.Has() { + u.ProhibitLogin = opts.ProhibitLogin.Value() + } + + if err := user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login"); err != nil { + return err + } + + if deleteAuthTokens { + return auth_model.DeleteAuthTokensByUserID(ctx, u.ID) + } + return nil +} diff --git a/services/user/update_test.go b/services/user/update_test.go new file mode 100644 index 00000000000..7ed764b5395 --- /dev/null +++ b/services/user/update_test.go @@ -0,0 +1,120 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + password_module "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + assert.Error(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{ + IsAdmin: optional.Some(false), + })) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + + opts := &UpdateOptions{ + KeepEmailPrivate: optional.Some(false), + FullName: optional.Some("Changed Name"), + Website: optional.Some("https://gitea.com/"), + Location: optional.Some("location"), + Description: optional.Some("description"), + AllowGitHook: optional.Some(true), + AllowImportLocal: optional.Some(true), + MaxRepoCreation: optional.Some[int](10), + IsRestricted: optional.Some(true), + IsActive: optional.Some(false), + IsAdmin: optional.Some(true), + Visibility: optional.Some(structs.VisibleTypePrivate), + KeepActivityPrivate: optional.Some(true), + Language: optional.Some("lang"), + Theme: optional.Some("theme"), + DiffViewStyle: optional.Some("split"), + AllowCreateOrganization: optional.Some(false), + EmailNotificationsPreference: optional.Some("disabled"), + SetLastLogin: true, + } + assert.NoError(t, UpdateUser(db.DefaultContext, user, opts)) + + assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate) + assert.Equal(t, opts.FullName.Value(), user.FullName) + assert.Equal(t, opts.Website.Value(), user.Website) + assert.Equal(t, opts.Location.Value(), user.Location) + assert.Equal(t, opts.Description.Value(), user.Description) + assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook) + assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal) + assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation) + assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted) + assert.Equal(t, opts.IsActive.Value(), user.IsActive) + assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin) + assert.Equal(t, opts.Visibility.Value(), user.Visibility) + assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate) + assert.Equal(t, opts.Language.Value(), user.Language) + assert.Equal(t, opts.Theme.Value(), user.Theme) + assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle) + assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization) + assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference) + + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate) + assert.Equal(t, opts.FullName.Value(), user.FullName) + assert.Equal(t, opts.Website.Value(), user.Website) + assert.Equal(t, opts.Location.Value(), user.Location) + assert.Equal(t, opts.Description.Value(), user.Description) + assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook) + assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal) + assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation) + assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted) + assert.Equal(t, opts.IsActive.Value(), user.IsActive) + assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin) + assert.Equal(t, opts.Visibility.Value(), user.Visibility) + assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate) + assert.Equal(t, opts.Language.Value(), user.Language) + assert.Equal(t, opts.Theme.Value(), user.Theme) + assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle) + assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization) + assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference) +} + +func TestUpdateAuth(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + copy := *user + + assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + LoginName: optional.Some("new-login"), + })) + assert.Equal(t, "new-login", user.LoginName) + + assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + Password: optional.Some("%$DRZUVB576tfzgu"), + MustChangePassword: optional.Some(true), + })) + assert.True(t, user.MustChangePassword) + assert.NotEqual(t, copy.Passwd, user.Passwd) + assert.NotEqual(t, copy.Salt, user.Salt) + + assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + ProhibitLogin: optional.Some(true), + })) + assert.True(t, user.ProhibitLogin) + + assert.ErrorIs(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + Password: optional.Some("aaaa"), + }), password_module.ErrMinLength) +} diff --git a/services/user/user.go b/services/user/user.go index 72bea0b4688..2287e36c716 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -11,7 +11,6 @@ import ( "time" "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" @@ -24,6 +23,8 @@ import ( "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/agit" + asymkey_service "code.gitea.io/gitea/services/asymkey" + org_service "code.gitea.io/gitea/services/org" "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" repo_service "code.gitea.io/gitea/services/repository" @@ -40,10 +41,7 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err } if newUserName == u.Name { - return user_model.ErrUsernameNotChanged{ - UID: u.ID, - Name: u.Name, - } + return nil } if err := user_model.IsUsableUsername(newUserName); err != nil { @@ -59,7 +57,7 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err u.Name = oldUserName return err } - return repo_model.UpdateRepositoryOwnerNames(u.ID, newUserName) + return repo_model.UpdateRepositoryOwnerNames(ctx, u.ID, newUserName) } ctx, committer, err := db.TxContext(ctx) @@ -128,6 +126,10 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return fmt.Errorf("%s is an organization not a user", u.Name) } + if u.IsActive && user_model.IsLastAdminUser(ctx, u) { + return models.ErrDeleteLastAdminUser{UID: u.ID} + } + if purge { // Disable the user first // NOTE: This is deliberately not within a transaction as it must disable the user immediately to prevent any further action by the user to be purged. @@ -158,27 +160,9 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { // // An alternative option here would be write a DeleteAllRepositoriesForUserID function which would delete all of the repos // but such a function would likely get out of date - for { - repos, _, err := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ - ListOptions: db.ListOptions{ - PageSize: repo_model.RepositoryListDefaultPageSize, - Page: 1, - }, - Private: true, - OwnerID: u.ID, - Actor: u, - }) - if err != nil { - return fmt.Errorf("GetUserRepositories: %w", err) - } - if len(repos) == 0 { - break - } - for _, repo := range repos { - if err := repo_service.DeleteRepositoryDirectly(ctx, u, u.ID, repo.ID); err != nil { - return fmt.Errorf("unable to delete repository %s for %s[%d]. Error: %w", repo.Name, u.Name, u.ID, err) - } - } + err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, u) + if err != nil { + return err } // Remove from Organizations and delete last owner organizations @@ -189,7 +173,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { // An alternative option here would be write a function which would delete all organizations but it seems // but such a function would likely get out of date for { - orgs, err := organization.FindOrgs(organization.FindOrgOptions{ + orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ ListOptions: db.ListOptions{ PageSize: repo_model.RepositoryListDefaultPageSize, Page: 1, @@ -204,9 +188,12 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { break } for _, org := range orgs { - if err := models.RemoveOrgUser(org.ID, u.ID); err != nil { + if err := models.RemoveOrgUser(ctx, org, u); err != nil { if organization.IsErrLastOrgOwner(err) { - err = organization.DeleteOrganization(ctx, org) + err = org_service.DeleteOrganization(ctx, org, true) + if err != nil { + return fmt.Errorf("unable to delete organization %d: %w", org.ID, err) + } } if err != nil { return fmt.Errorf("unable to remove user %s[%d] from org %s[%d]. Error: %w", u.Name, u.ID, org.Name, org.ID, err) @@ -223,7 +210,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { } } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } @@ -263,58 +250,54 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { if err := committer.Commit(); err != nil { return err } - committer.Close() + _ = committer.Close() - if err = asymkey_model.RewriteAllPublicKeys(); err != nil { + if err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return err } - if err = asymkey_model.RewriteAllPrincipalKeys(db.DefaultContext); err != nil { + if err = asymkey_service.RewriteAllPrincipalKeys(ctx); err != nil { return err } - // Note: There are something just cannot be roll back, - // so just keep error logs of those operations. + // Note: There are something just cannot be roll back, so just keep error logs of those operations. path := user_model.UserPath(u.Name) - if err := util.RemoveAll(path); err != nil { - err = fmt.Errorf("Failed to RemoveAll %s: %w", path, err) + if err = util.RemoveAll(path); err != nil { + err = fmt.Errorf("failed to RemoveAll %s: %w", path, err) _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err)) - return err } if u.Avatar != "" { avatarPath := u.CustomAvatarRelativePath() - if err := storage.Avatars.Delete(avatarPath); err != nil { - err = fmt.Errorf("Failed to remove %s: %w", avatarPath, err) + if err = storage.Avatars.Delete(avatarPath); err != nil { + err = fmt.Errorf("failed to remove %s: %w", avatarPath, err) _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err)) - return err } } return nil } -// DeleteInactiveUsers deletes all inactive users and email addresses. +// DeleteInactiveUsers deletes all inactive users and their email addresses. func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { - users, err := user_model.GetInactiveUsers(ctx, olderThan) + inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan) if err != nil { return err } // FIXME: should only update authorized_keys file once after all deletions. - for _, u := range users { - select { - case <-ctx.Done(): - return db.ErrCancelledf("Before delete inactive user %s", u.Name) - default: - } - if err := DeleteUser(ctx, u, false); err != nil { - // Ignore users that were set inactive by admin. + for _, u := range inactiveUsers { + if err = DeleteUser(ctx, u, false); err != nil { + // Ignore inactive users that were ever active but then were set inactive by admin if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || models.IsErrUserOwnPackages(err) { continue } - return err + select { + case <-ctx.Done(): + return db.ErrCancelledf("when deleting inactive user %q", u.Name) + default: + return err + } } } - - return user_model.DeleteInactiveEmailAddresses(ctx) + return nil // TODO: there could be still inactive users left, and the number would increase gradually } diff --git a/services/user/user_test.go b/services/user/user_test.go index 7a9713c79f5..bd6019a14fd 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -5,9 +5,9 @@ package user import ( "fmt" - "path/filepath" "strings" "testing" + "time" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/auth" @@ -17,14 +17,13 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestDeleteUser(t *testing.T) { @@ -44,7 +43,8 @@ func TestDeleteUser(t *testing.T) { orgUsers := make([]*organization.OrgUser, 0, 10) assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID})) for _, orgUser := range orgUsers { - if err := models.RemoveOrgUser(orgUser.OrgID, orgUser.UID); err != nil { + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID}) + if err := models.RemoveOrgUser(db.DefaultContext, org, user); err != nil { assert.True(t, organization.IsErrLastOrgOwner(err)) return } @@ -110,7 +110,7 @@ func TestRenameUser(t *testing.T) { }) t.Run("Same username", func(t *testing.T) { - assert.ErrorIs(t, RenameUser(db.DefaultContext, user, user.Name), user_model.ErrUsernameNotChanged{UID: user.ID, Name: user.Name}) + assert.NoError(t, RenameUser(db.DefaultContext, user, user.Name)) }) t.Run("Non usable username", func(t *testing.T) { @@ -150,7 +150,7 @@ func TestRenameUser(t *testing.T) { assert.NoError(t, RenameUser(db.DefaultContext, user, newUsername)) unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: newUsername, LowerName: strings.ToLower(newUsername)}) - redirectUID, err := user_model.LookupUserRedirect(oldUsername) + redirectUID, err := user_model.LookupUserRedirect(db.DefaultContext, oldUsername) assert.NoError(t, err) assert.EqualValues(t, user.ID, redirectUID) @@ -187,3 +187,26 @@ func TestCreateUser_Issue5882(t *testing.T) { assert.NoError(t, DeleteUser(db.DefaultContext, v.user, false)) } } + +func TestDeleteInactiveUsers(t *testing.T) { + addUser := func(name, email string, createdUnix timeutil.TimeStamp, active bool) { + inactiveUser := &user_model.User{Name: name, LowerName: strings.ToLower(name), Email: email, CreatedUnix: createdUnix, IsActive: active} + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(inactiveUser) + assert.NoError(t, err) + inactiveUserEmail := &user_model.EmailAddress{UID: inactiveUser.ID, IsPrimary: true, Email: email, LowerEmail: strings.ToLower(email), IsActivated: active} + err = db.Insert(db.DefaultContext, inactiveUserEmail) + assert.NoError(t, err) + } + addUser("user-inactive-10", "user-inactive-10@test.com", timeutil.TimeStampNow().Add(-600), false) + addUser("user-inactive-5", "user-inactive-5@test.com", timeutil.TimeStampNow().Add(-300), false) + addUser("user-active-10", "user-active-10@test.com", timeutil.TimeStampNow().Add(-600), true) + addUser("user-active-5", "user-active-5@test.com", timeutil.TimeStampNow().Add(-300), true) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-10"}) + unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) + assert.NoError(t, DeleteInactiveUsers(db.DefaultContext, 8*time.Minute)) + unittest.AssertNotExistsBean(t, &user_model.User{Name: "user-inactive-10"}) + unittest.AssertNotExistsBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-5"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-10"}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-5"}) +} diff --git a/services/webhook/deliver.go b/services/webhook/deliver.go index fd7a3d7fbae..b2c0a73784d 100644 --- a/services/webhook/deliver.go +++ b/services/webhook/deliver.go @@ -7,6 +7,7 @@ import ( "context" "crypto/hmac" "crypto/sha1" + "crypto/sha256" "crypto/tls" "encoding/hex" "fmt" @@ -29,39 +30,19 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/gobwas/glob" - "github.com/minio/sha256-simd" ) -// Deliver deliver hook task -func Deliver(ctx context.Context, t *webhook_model.HookTask) error { - w, err := webhook_model.GetWebhookByID(t.HookID) - if err != nil { - return err - } - - defer func() { - err := recover() - if err == nil { - return - } - // There was a panic whilst delivering a hook... - log.Error("PANIC whilst trying to deliver webhook task[%d] to webhook %s Panic: %v\nStacktrace: %s", t.ID, w.URL, err, log.Stack(2)) - }() - - t.IsDelivered = true - - var req *http.Request - +func newDefaultRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) { switch w.HTTPMethod { case "": - log.Info("HTTP Method for webhook %s empty, setting to POST as default", w.URL) + log.Info("HTTP Method for %s webhook %s [ID: %d] is not set, defaulting to POST", w.Type, w.URL, w.ID) fallthrough case http.MethodPost: switch w.ContentType { case webhook_model.ContentTypeJSON: req, err = http.NewRequest("POST", w.URL, strings.NewReader(t.PayloadContent)) if err != nil { - return err + return nil, nil, err } req.Header.Set("Content-Type", "application/json") @@ -72,50 +53,58 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { req, err = http.NewRequest("POST", w.URL, strings.NewReader(forms.Encode())) if err != nil { - return err + return nil, nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + default: + return nil, nil, fmt.Errorf("invalid content type: %v", w.ContentType) } case http.MethodGet: u, err := url.Parse(w.URL) if err != nil { - return fmt.Errorf("unable to deliver webhook task[%d] as cannot parse webhook url %s: %w", t.ID, w.URL, err) + return nil, nil, fmt.Errorf("invalid URL: %w", err) } vals := u.Query() vals["payload"] = []string{t.PayloadContent} u.RawQuery = vals.Encode() req, err = http.NewRequest("GET", u.String(), nil) if err != nil { - return fmt.Errorf("unable to deliver webhook task[%d] as unable to create HTTP request for webhook url %s: %w", t.ID, w.URL, err) + return nil, nil, err } case http.MethodPut: switch w.Type { - case webhook_module.MATRIX: + case webhook_module.MATRIX: // used when t.Version == 1 txnID, err := getMatrixTxnID([]byte(t.PayloadContent)) if err != nil { - return err + return nil, nil, err } url := fmt.Sprintf("%s/%s", w.URL, url.PathEscape(txnID)) req, err = http.NewRequest("PUT", url, strings.NewReader(t.PayloadContent)) if err != nil { - return fmt.Errorf("unable to deliver webhook task[%d] as cannot create matrix request for webhook url %s: %w", t.ID, w.URL, err) + return nil, nil, err } default: - return fmt.Errorf("invalid http method for webhook task[%d] in webhook %s: %v", t.ID, w.URL, w.HTTPMethod) + return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod) } default: - return fmt.Errorf("invalid http method for webhook task[%d] in webhook %s: %v", t.ID, w.URL, w.HTTPMethod) + return nil, nil, fmt.Errorf("invalid http method: %v", w.HTTPMethod) } + body = []byte(t.PayloadContent) + return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) +} + +func addDefaultHeaders(req *http.Request, secret []byte, t *webhook_model.HookTask, payloadContent []byte) error { var signatureSHA1 string var signatureSHA256 string - if len(w.Secret) > 0 { - sig1 := hmac.New(sha1.New, []byte(w.Secret)) - sig256 := hmac.New(sha256.New, []byte(w.Secret)) - _, err = io.MultiWriter(sig1, sig256).Write([]byte(t.PayloadContent)) + if len(secret) > 0 { + sig1 := hmac.New(sha1.New, secret) + sig256 := hmac.New(sha256.New, secret) + _, err := io.MultiWriter(sig1, sig256).Write(payloadContent) if err != nil { - log.Error("prepareWebhooks.sigWrite: %v", err) + // this error should never happen, since the hashes are writing to []byte and always return a nil error. + return fmt.Errorf("prepareWebhooks.sigWrite: %w", err) } signatureSHA1 = hex.EncodeToString(sig1.Sum(nil)) signatureSHA256 = hex.EncodeToString(sig256.Sum(nil)) @@ -136,15 +125,36 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { req.Header["X-GitHub-Delivery"] = []string{t.UUID} req.Header["X-GitHub-Event"] = []string{event} req.Header["X-GitHub-Event-Type"] = []string{eventType} + return nil +} - // Add Authorization Header - authorization, err := w.HeaderAuthorization() +// Deliver creates the [http.Request] (depending on the webhook type), sends it +// and records the status and response. +func Deliver(ctx context.Context, t *webhook_model.HookTask) error { + w, err := webhook_model.GetWebhookByID(ctx, t.HookID) if err != nil { - log.Error("Webhook could not get Authorization header [%d]: %v", w.ID, err) return err } - if authorization != "" { - req.Header["Authorization"] = []string{authorization} + + defer func() { + err := recover() + if err == nil { + return + } + // There was a panic whilst delivering a hook... + log.Error("PANIC whilst trying to deliver webhook task[%d] to webhook %s Panic: %v\nStacktrace: %s", t.ID, w.URL, err, log.Stack(2)) + }() + + t.IsDelivered = true + + newRequest := webhookRequesters[w.Type] + if t.PayloadVersion == 1 || newRequest == nil { + newRequest = newDefaultRequest + } + + req, body, err := newRequest(ctx, w, t) + if err != nil { + return fmt.Errorf("cannot create http request for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err) } // Record delivery information. @@ -152,11 +162,22 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { URL: req.URL.String(), HTTPMethod: req.Method, Headers: map[string]string{}, + Body: string(body), } for k, vals := range req.Header { t.RequestInfo.Headers[k] = strings.Join(vals, ",") } + // Add Authorization Header + authorization, err := w.HeaderAuthorization() + if err != nil { + return fmt.Errorf("cannot get Authorization header for webhook %s[%d %s]: %w", w.Type, w.ID, w.URL, err) + } + if authorization != "" { + req.Header.Set("Authorization", authorization) + t.RequestInfo.Headers["Authorization"] = "******" + } + t.ResponseInfo = &webhook_model.HookResponse{ Headers: map[string]string{}, } @@ -185,7 +206,7 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { log.Trace("Hook delivery failed: %s", t.UUID) } - if err := webhook_model.UpdateHookTask(t); err != nil { + if err := webhook_model.UpdateHookTask(ctx, t); err != nil { log.Error("UpdateHookTask [%d]: %v", t.ID, err) } @@ -195,7 +216,7 @@ func Deliver(ctx context.Context, t *webhook_model.HookTask) error { } else { w.LastStatus = webhook_module.HookStatusFail } - if err = webhook_model.UpdateWebhookLastStatus(w); err != nil { + if err = webhook_model.UpdateWebhookLastStatus(ctx, w); err != nil { log.Error("UpdateWebhookLastStatus: %v", err) return } @@ -239,7 +260,7 @@ var ( hostMatchers []glob.Glob ) -func webhookProxy() func(req *http.Request) (*url.URL, error) { +func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request) (*url.URL, error) { if setting.Webhook.ProxyURL == "" { return proxy.Proxy() } @@ -257,6 +278,9 @@ func webhookProxy() func(req *http.Request) (*url.URL, error) { return func(req *http.Request) (*url.URL, error) { for _, v := range hostMatchers { if v.Match(req.URL.Host) { + if !allowList.MatchHostName(req.URL.Host) { + return nil, fmt.Errorf("webhook can only call allowed HTTP servers (check your %s setting), deny '%s'", allowList.SettingKeyHint, req.URL.Host) + } return http.ProxyURL(setting.Webhook.ProxyURLFixed)(req) } } @@ -278,8 +302,8 @@ func Init() error { Timeout: timeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify}, - Proxy: webhookProxy(), - DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil), + Proxy: webhookProxy(allowedHostMatcher), + DialContext: hostmatcher.NewDialContextWithProxy("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed), }, } diff --git a/services/webhook/deliver_test.go b/services/webhook/deliver_test.go index ee63975ad37..d0cfc1598f1 100644 --- a/services/webhook/deliver_test.go +++ b/services/webhook/deliver_test.go @@ -5,44 +5,83 @@ package webhook import ( "context" + "io" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/hostmatcher" "code.gitea.io/gitea/modules/setting" - api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWebhookProxy(t *testing.T) { + oldWebhook := setting.Webhook + t.Cleanup(func() { + setting.Webhook = oldWebhook + }) + setting.Webhook.ProxyURL = "http://localhost:8080" setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL) setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"} - kases := map[string]string{ - "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx": "http://localhost:8080", - "http://s.discordapp.com/assets/xxxxxx": "http://localhost:8080", - "http://github.com/a/b": "", + allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", "discordapp.com,s.discordapp.com") + + tests := []struct { + req string + want string + wantErr bool + }{ + { + req: "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx", + want: "http://localhost:8080", + wantErr: false, + }, + { + req: "http://s.discordapp.com/assets/xxxxxx", + want: "http://localhost:8080", + wantErr: false, + }, + { + req: "http://github.com/a/b", + want: "", + wantErr: false, + }, + { + req: "http://www.discordapp.com/assets/xxxxxx", + want: "", + wantErr: true, + }, } + for _, tt := range tests { + t.Run(tt.req, func(t *testing.T) { + req, err := http.NewRequest("POST", tt.req, nil) + require.NoError(t, err) - for reqURL, proxyURL := range kases { - req, err := http.NewRequest("POST", reqURL, nil) - assert.NoError(t, err) + u, err := webhookProxy(allowedHostMatcher)(req) + if tt.wantErr { + assert.Error(t, err) + return + } - u, err := webhookProxy()(req) - assert.NoError(t, err) - if proxyURL == "" { - assert.Nil(t, u) - } else { - assert.EqualValues(t, proxyURL, u.String()) - } + assert.NoError(t, err) + + got := "" + if u != nil { + got = u.String() + } + assert.Equal(t, tt.want, got) + }) } } @@ -68,15 +107,16 @@ func TestWebhookDeliverAuthorizationHeader(t *testing.T) { err := hook.SetHeaderAuthorization("Bearer s3cr3t-t0ken") assert.NoError(t, err) assert.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook)) - db.GetEngine(db.DefaultContext).NoAutoTime().DB().Logger.ShowSQL(true) - hookTask := &webhook_model.HookTask{HookID: hook.ID, EventType: webhook_module.HookEventPush, Payloader: &api.PushPayload{}} + hookTask := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadVersion: 2, + } hookTask, err = webhook_model.CreateHookTask(db.DefaultContext, hookTask) assert.NoError(t, err) - if !assert.NotNil(t, hookTask) { - return - } + assert.NotNil(t, hookTask) assert.NoError(t, Deliver(context.Background(), hookTask)) select { @@ -86,4 +126,171 @@ func TestWebhookDeliverAuthorizationHeader(t *testing.T) { } assert.True(t, hookTask.IsSucceed) + assert.Equal(t, "******", hookTask.RequestInfo.Headers["Authorization"]) +} + +func TestWebhookDeliverHookTask(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + done := make(chan struct{}, 1) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + switch r.URL.Path { + case "/webhook/66d222a5d6349e1311f551e50722d837e30fce98": + // Version 1 + assert.Equal(t, "push", r.Header.Get("X-GitHub-Event")) + assert.Equal(t, "", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, `{"data": 42}`, string(body)) + + case "/webhook/6db5dc1e282529a8c162c7fe93dd2667494eeb51": + // Version 2 + assert.Equal(t, "push", r.Header.Get("X-GitHub-Event")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Len(t, body, 2147) + + default: + w.WriteHeader(404) + t.Fatalf("unexpected url path %s", r.URL.Path) + return + } + w.WriteHeader(200) + done <- struct{}{} + })) + t.Cleanup(s.Close) + + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.MATRIX, + URL: s.URL + "/webhook", + HTTPMethod: "PUT", + ContentType: webhook_model.ContentTypeJSON, + Meta: `{"message_type":0}`, // text + } + assert.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook)) + + t.Run("Version 1", func(t *testing.T) { + hookTask := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: `{"data": 42}`, + PayloadVersion: 1, + } + + hookTask, err := webhook_model.CreateHookTask(db.DefaultContext, hookTask) + assert.NoError(t, err) + assert.NotNil(t, hookTask) + + assert.NoError(t, Deliver(context.Background(), hookTask)) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("waited to long for request to happen") + } + + assert.True(t, hookTask.IsSucceed) + }) + + t.Run("Version 2", func(t *testing.T) { + p := pushTestPayload() + data, err := p.JSONPayload() + assert.NoError(t, err) + + hookTask := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + hookTask, err = webhook_model.CreateHookTask(db.DefaultContext, hookTask) + assert.NoError(t, err) + assert.NotNil(t, hookTask) + + assert.NoError(t, Deliver(context.Background(), hookTask)) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("waited to long for request to happen") + } + + assert.True(t, hookTask.IsSucceed) + }) +} + +func TestWebhookDeliverSpecificTypes(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + type hookCase struct { + gotBody chan []byte + httpMethod string // default to POST + } + + cases := map[string]*hookCase{ + webhook_module.SLACK: {}, + webhook_module.DISCORD: {}, + webhook_module.DINGTALK: {}, + webhook_module.TELEGRAM: {}, + webhook_module.MSTEAMS: {}, + webhook_module.FEISHU: {}, + webhook_module.MATRIX: {httpMethod: "PUT"}, + webhook_module.WECHATWORK: {}, + webhook_module.PACKAGIST: {}, + } + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + typ := strings.Split(r.URL.Path, "/")[1] // URL: "/{webhook_type}/other-path" + assert.Equal(t, "application/json", r.Header.Get("Content-Type"), r.URL.Path) + assert.Equal(t, util.IfZero(cases[typ].httpMethod, "POST"), r.Method, "webhook test request %q", r.URL.Path) + body, _ := io.ReadAll(r.Body) // read request and send it back to the test by testcase's chan + cases[typ].gotBody <- body + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(s.Close) + + p := pushTestPayload() + data, err := p.JSONPayload() + assert.NoError(t, err) + + for typ := range cases { + cases[typ].gotBody = make(chan []byte, 1) + t.Run(typ, func(t *testing.T) { + t.Parallel() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: typ, + URL: s.URL + "/" + typ, + Meta: "{}", + } + assert.NoError(t, webhook_model.CreateWebhook(db.DefaultContext, hook)) + + hookTask := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + hookTask, err := webhook_model.CreateHookTask(db.DefaultContext, hookTask) + assert.NoError(t, err) + assert.NotNil(t, hookTask) + + assert.NoError(t, Deliver(context.Background(), hookTask)) + + select { + case gotBody := <-cases[typ].gotBody: + assert.NotEqual(t, string(data), string(gotBody), "request body must be different from the event payload") + assert.Equal(t, hookTask.RequestInfo.Body, string(gotBody), "delivered webhook payload doesn't match saved request") + case <-time.After(5 * time.Second): + t.Fatal("waited to long for request to happen") + } + + assert.True(t, hookTask.IsSucceed) + }) + } } diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 69fae03299a..c57d04415ae 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -4,12 +4,14 @@ package webhook import ( + "context" "fmt" + "net/http" "net/url" "strings" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -22,19 +24,8 @@ type ( DingtalkPayload dingtalk.Payload ) -var _ PayloadConvertor = &DingtalkPayload{} - -// JSONPayload Marshals the DingtalkPayload to json -func (d *DingtalkPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - // Create implements PayloadConvertor Create method -func (d *DingtalkPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Create(p *api.CreatePayload) (DingtalkPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) @@ -43,7 +34,7 @@ func (d *DingtalkPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete implements PayloadConvertor Delete method -func (d *DingtalkPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Delete(p *api.DeletePayload) (DingtalkPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) @@ -52,14 +43,14 @@ func (d *DingtalkPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork implements PayloadConvertor Fork method -func (d *DingtalkPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Fork(p *api.ForkPayload) (DingtalkPayload, error) { title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) return createDingtalkPayload(title, title, fmt.Sprintf("view forked repo %s", p.Repo.FullName), p.Repo.HTMLURL), nil } // Push implements PayloadConvertor Push method -func (d *DingtalkPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Push(p *api.PushPayload) (DingtalkPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -100,14 +91,14 @@ func (d *DingtalkPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (d *DingtalkPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Issue(p *api.IssuePayload) (DingtalkPayload, error) { text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, true) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view issue", p.Issue.HTMLURL), nil } // Wiki implements PayloadConvertor Wiki method -func (d *DingtalkPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Wiki(p *api.WikiPayload) (DingtalkPayload, error) { text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true) url := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page) @@ -115,27 +106,27 @@ func (d *DingtalkPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { } // IssueComment implements PayloadConvertor IssueComment method -func (d *DingtalkPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) IssueComment(p *api.IssueCommentPayload) (DingtalkPayload, error) { text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, true) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+p.Comment.Body, "view issue comment", p.Comment.HTMLURL), nil } // PullRequest implements PayloadConvertor PullRequest method -func (d *DingtalkPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) PullRequest(p *api.PullRequestPayload) (DingtalkPayload, error) { text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, true) return createDingtalkPayload(issueTitle, text+"\r\n\r\n"+attachmentText, "view pull request", p.PullRequest.HTMLURL), nil } // Review implements PayloadConvertor Review method -func (d *DingtalkPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (dc dingtalkConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (DingtalkPayload, error) { var text, title string switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return DingtalkPayload{}, err } title = fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) @@ -146,14 +137,14 @@ func (d *DingtalkPayload) Review(p *api.PullRequestPayload, event webhook_module } // Repository implements PayloadConvertor Repository method -func (d *DingtalkPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Repository(p *api.RepositoryPayload) (DingtalkPayload, error) { switch p.Action { case api.HookRepoCreated: title := fmt.Sprintf("[%s] Repository created", p.Repository.FullName) return createDingtalkPayload(title, title, "view repository", p.Repository.HTMLURL), nil case api.HookRepoDeleted: title := fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName) - return &DingtalkPayload{ + return DingtalkPayload{ MsgType: "text", Text: struct { Content string `json:"content"` @@ -163,18 +154,24 @@ func (d *DingtalkPayload) Repository(p *api.RepositoryPayload) (api.Payloader, e }, nil } - return nil, nil + return DingtalkPayload{}, nil } // Release implements PayloadConvertor Release method -func (d *DingtalkPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (dc dingtalkConvertor) Release(p *api.ReleasePayload) (DingtalkPayload, error) { text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true) - return createDingtalkPayload(text, text, "view release", p.Release.URL), nil + return createDingtalkPayload(text, text, "view release", p.Release.HTMLURL), nil } -func createDingtalkPayload(title, text, singleTitle, singleURL string) *DingtalkPayload { - return &DingtalkPayload{ +func (dc dingtalkConvertor) Package(p *api.PackagePayload) (DingtalkPayload, error) { + text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true) + + return createDingtalkPayload(text, text, "view package", p.Package.HTMLURL), nil +} + +func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload { + return DingtalkPayload{ MsgType: "actionCard", ActionCard: dingtalk.ActionCard{ Text: strings.TrimSpace(text), @@ -189,7 +186,10 @@ func createDingtalkPayload(title, text, singleTitle, singleURL string) *Dingtalk } } -// GetDingtalkPayload converts a ding talk webhook into a DingtalkPayload -func GetDingtalkPayload(p api.Payloader, event webhook_module.HookEventType, _ string) (api.Payloader, error) { - return convertPayloader(new(DingtalkPayload), p, event) +type dingtalkConvertor struct{} + +var _ payloadConvertor[DingtalkPayload] = dingtalkConvertor{} + +func newDingtalkRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + return newJSONRequest(dingtalkConvertor{}, w, t, true) } diff --git a/services/webhook/dingtalk_test.go b/services/webhook/dingtalk_test.go index e3122d2f36f..25f47347d01 100644 --- a/services/webhook/dingtalk_test.go +++ b/services/webhook/dingtalk_test.go @@ -4,9 +4,12 @@ package webhook import ( + "context" "net/url" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -24,233 +27,226 @@ func TestDingTalkPayload(t *testing.T) { } return "" } + dc := dingtalkConvertor{} t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(DingtalkPayload) - pl, err := d.Create(p) + pl, err := dc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] branch test created", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] branch test created", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view ref test", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] branch test created", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] branch test created", pl.ActionCard.Title) + assert.Equal(t, "view ref test", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(DingtalkPayload) - pl, err := d.Delete(p) + pl, err := dc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] branch test deleted", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] branch test deleted", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view ref test", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] branch test deleted", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] branch test deleted", pl.ActionCard.Title) + assert.Equal(t, "view ref test", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(DingtalkPayload) - pl, err := d.Fork(p) + pl, err := dc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view forked repo test/repo", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "test/repo2 is forked to test/repo", pl.ActionCard.Text) + assert.Equal(t, "test/repo2 is forked to test/repo", pl.ActionCard.Title) + assert.Equal(t, "view forked repo test/repo", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(DingtalkPayload) - pl, err := d.Push(p) + pl, err := dc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo:test] 2 new commits", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view commits", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo:test] 2 new commits", pl.ActionCard.Title) + assert.Equal(t, "view commits", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(DingtalkPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := dc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\r\n\r\nissue body", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "#2 crash", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view issue", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\r\n\r\nissue body", pl.ActionCard.Text) + assert.Equal(t, "#2 crash", pl.ActionCard.Title) + assert.Equal(t, "view issue", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.ActionCard.SingleURL)) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = dc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Issue closed: #2 crash by user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "#2 crash", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view issue", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Issue closed: #2 crash by user1", pl.ActionCard.Text) + assert.Equal(t, "#2 crash", pl.ActionCard.Title) + assert.Equal(t, "view issue", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(DingtalkPayload) - pl, err := d.IssueComment(p) + pl, err := dc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] New comment on issue #2 crash by user1\r\n\r\nmore info needed", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "#2 crash", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view issue comment", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] New comment on issue #2 crash by user1\r\n\r\nmore info needed", pl.ActionCard.Text) + assert.Equal(t, "#2 crash", pl.ActionCard.Title) + assert.Equal(t, "view issue comment", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(DingtalkPayload) - pl, err := d.PullRequest(p) + pl, err := dc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug by user1\r\n\r\nfixes bug #2", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "#12 Fix bug", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view pull request", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug by user1\r\n\r\nfixes bug #2", pl.ActionCard.Text) + assert.Equal(t, "#12 Fix bug", pl.ActionCard.Title) + assert.Equal(t, "view pull request", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(DingtalkPayload) - pl, err := d.IssueComment(p) + pl, err := dc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug by user1\r\n\r\nchanges requested", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "#12 Fix bug", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view issue comment", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug by user1\r\n\r\nchanges requested", pl.ActionCard.Text) + assert.Equal(t, "#12 Fix bug", pl.ActionCard.Title) + assert.Equal(t, "view issue comment", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(DingtalkPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := dc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view pull request", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug", pl.ActionCard.Title) + assert.Equal(t, "view pull request", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(DingtalkPayload) - pl, err := d.Repository(p) + pl, err := dc.Repository(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Repository created", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] Repository created", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view repository", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Repository created", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Repository created", pl.ActionCard.Title) + assert.Equal(t, "view repository", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo", parseRealSingleURL(pl.ActionCard.SingleURL)) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := dc.Package(p) + require.NoError(t, err) + + assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.ActionCard.Text) + assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.ActionCard.Title) + assert.Equal(t, "view package", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(DingtalkPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view wiki", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.ActionCard.Title) + assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view wiki", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.ActionCard.Title) + assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view wiki", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.ActionCard.Title) + assert.Equal(t, "view wiki", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", parseRealSingleURL(pl.ActionCard.SingleURL)) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(DingtalkPayload) - pl, err := d.Release(p) + pl, err := dc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.(*DingtalkPayload).ActionCard.Text) - assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.(*DingtalkPayload).ActionCard.Title) - assert.Equal(t, "view release", pl.(*DingtalkPayload).ActionCard.SingleTitle) - assert.Equal(t, "http://localhost:3000/api/v1/repos/test/repo/releases/2", parseRealSingleURL(pl.(*DingtalkPayload).ActionCard.SingleURL)) + assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.ActionCard.Text) + assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.ActionCard.Title) + assert.Equal(t, "view release", pl.ActionCard.SingleTitle) + assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", parseRealSingleURL(pl.ActionCard.SingleURL)) }) } func TestDingTalkJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(DingtalkPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DingtalkPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.DINGTALK, + URL: "https://dingtalk.example.com/", + Meta: ``, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newDingtalkRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://dingtalk.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body DingtalkPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.ActionCard.Text) } diff --git a/services/webhook/discord.go b/services/webhook/discord.go index 204587eca5b..659754d5e05 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -4,8 +4,10 @@ package webhook import ( + "context" "errors" "fmt" + "net/http" "net/url" "strconv" "strings" @@ -98,19 +100,8 @@ var ( redColor = color("ff3232") ) -// JSONPayload Marshals the DiscordPayload to json -func (d *DiscordPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - -var _ PayloadConvertor = &DiscordPayload{} - // Create implements PayloadConvertor Create method -func (d *DiscordPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (d discordConvertor) Create(p *api.CreatePayload) (DiscordPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) @@ -119,7 +110,7 @@ func (d *DiscordPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete implements PayloadConvertor Delete method -func (d *DiscordPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (d discordConvertor) Delete(p *api.DeletePayload) (DiscordPayload, error) { // deleted tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) @@ -128,14 +119,14 @@ func (d *DiscordPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork implements PayloadConvertor Fork method -func (d *DiscordPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (d discordConvertor) Fork(p *api.ForkPayload) (DiscordPayload, error) { title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) return d.createPayload(p.Sender, title, "", p.Repo.HTMLURL, greenColor), nil } // Push implements PayloadConvertor Push method -func (d *DiscordPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (d discordConvertor) Push(p *api.PushPayload) (DiscordPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -170,35 +161,35 @@ func (d *DiscordPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (d *DiscordPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (d discordConvertor) Issue(p *api.IssuePayload) (DiscordPayload, error) { title, _, text, color := getIssuesPayloadInfo(p, noneLinkFormatter, false) return d.createPayload(p.Sender, title, text, p.Issue.HTMLURL, color), nil } // IssueComment implements PayloadConvertor IssueComment method -func (d *DiscordPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (d discordConvertor) IssueComment(p *api.IssueCommentPayload) (DiscordPayload, error) { title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, false) return d.createPayload(p.Sender, title, p.Comment.Body, p.Comment.HTMLURL, color), nil } // PullRequest implements PayloadConvertor PullRequest method -func (d *DiscordPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (d discordConvertor) PullRequest(p *api.PullRequestPayload) (DiscordPayload, error) { title, _, text, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false) return d.createPayload(p.Sender, title, text, p.PullRequest.HTMLURL, color), nil } // Review implements PayloadConvertor Review method -func (d *DiscordPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (d discordConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (DiscordPayload, error) { var text, title string var color int switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return DiscordPayload{}, err } title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) @@ -220,7 +211,7 @@ func (d *DiscordPayload) Review(p *api.PullRequestPayload, event webhook_module. } // Repository implements PayloadConvertor Repository method -func (d *DiscordPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (d discordConvertor) Repository(p *api.RepositoryPayload) (DiscordPayload, error) { var title, url string var color int switch p.Action { @@ -237,7 +228,7 @@ func (d *DiscordPayload) Repository(p *api.RepositoryPayload) (api.Payloader, er } // Wiki implements PayloadConvertor Wiki method -func (d *DiscordPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (d discordConvertor) Wiki(p *api.WikiPayload) (DiscordPayload, error) { text, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, false) htmlLink := p.Repository.HTMLURL + "/wiki/" + url.PathEscape(p.Page) @@ -250,24 +241,35 @@ func (d *DiscordPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { } // Release implements PayloadConvertor Release method -func (d *DiscordPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (d discordConvertor) Release(p *api.ReleasePayload) (DiscordPayload, error) { text, color := getReleasePayloadInfo(p, noneLinkFormatter, false) - return d.createPayload(p.Sender, text, p.Release.Note, p.Release.URL, color), nil + return d.createPayload(p.Sender, text, p.Release.Note, p.Release.HTMLURL, color), nil } -// GetDiscordPayload converts a discord webhook into a DiscordPayload -func GetDiscordPayload(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) { - s := new(DiscordPayload) +func (d discordConvertor) Package(p *api.PackagePayload) (DiscordPayload, error) { + text, color := getPackagePayloadInfo(p, noneLinkFormatter, false) - discord := &DiscordMeta{} - if err := json.Unmarshal([]byte(meta), &discord); err != nil { - return s, errors.New("GetDiscordPayload meta json:" + err.Error()) - } - s.Username = discord.Username - s.AvatarURL = discord.IconURL + return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil +} - return convertPayloader(s, p, event) +type discordConvertor struct { + Username string + AvatarURL string +} + +var _ payloadConvertor[DiscordPayload] = discordConvertor{} + +func newDiscordRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + meta := &DiscordMeta{} + if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { + return nil, nil, fmt.Errorf("newDiscordRequest meta json: %w", err) + } + sc := discordConvertor{ + Username: meta.Username, + AvatarURL: meta.IconURL, + } + return newJSONRequest(sc, w, t, true) } func parseHookPullRequestEventType(event webhook_module.HookEventType) (string, error) { @@ -285,8 +287,8 @@ func parseHookPullRequestEventType(event webhook_module.HookEventType) (string, } } -func (d *DiscordPayload) createPayload(s *api.User, title, text, url string, color int) *DiscordPayload { - return &DiscordPayload{ +func (d discordConvertor) createPayload(s *api.User, title, text, url string, color int) DiscordPayload { + return DiscordPayload{ Username: d.Username, AvatarURL: d.AvatarURL, Embeds: []DiscordEmbed{ diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go index 624d53446a9..c04b95383bf 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -15,277 +18,274 @@ import ( ) func TestDiscordPayload(t *testing.T) { + dc := discordConvertor{} + t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(DiscordPayload) - pl, err := d.Create(p) + pl, err := dc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] branch test created", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] branch test created", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(DiscordPayload) - pl, err := d.Delete(p) + pl, err := dc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] branch test deleted", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] branch test deleted", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(DiscordPayload) - pl, err := d.Fork(p) + pl, err := dc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "test/repo2 is forked to test/repo", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(DiscordPayload) - pl, err := d.Push(p) + pl, err := dc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo:test] 2 new commits", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo:test] 2 new commits", pl.Embeds[0].Title) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(DiscordPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := dc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "issue body", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Embeds[0].Title) + assert.Equal(t, "issue body", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = dc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(DiscordPayload) - pl, err := d.IssueComment(p) + pl, err := dc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "more info needed", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Embeds[0].Title) + assert.Equal(t, "more info needed", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(DiscordPayload) - pl, err := d.PullRequest(p) + pl, err := dc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "fixes bug #2", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Embeds[0].Title) + assert.Equal(t, "fixes bug #2", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(DiscordPayload) - pl, err := d.IssueComment(p) + pl, err := dc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "changes requested", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Embeds[0].Title) + assert.Equal(t, "changes requested", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(DiscordPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := dc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "good job", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Embeds[0].Title) + assert.Equal(t, "good job", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(DiscordPayload) - pl, err := d.Repository(p) + pl, err := dc.Repository(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Repository created", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Repository created", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := dc.Package(p) + require.NoError(t, err) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "Package created: GiteaContainer:latest", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(DiscordPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "Wiki change comment", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Embeds[0].Title) + assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "Wiki change comment", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Embeds[0].Title) + assert.Equal(t, "Wiki change comment", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = dc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.(*DiscordPayload).Embeds[0].Title) - assert.Empty(t, pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(DiscordPayload) - pl, err := d.Release(p) + pl, err := dc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - - assert.Len(t, pl.(*DiscordPayload).Embeds, 1) - assert.Equal(t, "[test/repo] Release created: v1.0", pl.(*DiscordPayload).Embeds[0].Title) - assert.Equal(t, "Note of first stable release", pl.(*DiscordPayload).Embeds[0].Description) - assert.Equal(t, "http://localhost:3000/api/v1/repos/test/repo/releases/2", pl.(*DiscordPayload).Embeds[0].URL) - assert.Equal(t, p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.Name) - assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.(*DiscordPayload).Embeds[0].Author.URL) - assert.Equal(t, p.Sender.AvatarURL, pl.(*DiscordPayload).Embeds[0].Author.IconURL) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "[test/repo] Release created: v1.0", pl.Embeds[0].Title) + assert.Equal(t, "Note of first stable release", pl.Embeds[0].Description) + assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) } func TestDiscordJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(DiscordPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &DiscordPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.DISCORD, + URL: "https://discord.example.com/", + Meta: `{}`, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newDiscordRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://discord.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body DiscordPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Embeds[0].Description) } diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index 089f51952fd..1ec436894b0 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -4,11 +4,13 @@ package webhook import ( + "context" "fmt" + "net/http" "strings" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -16,15 +18,15 @@ import ( type ( // FeishuPayload represents FeishuPayload struct { - MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive + MsgType string `json:"msg_type"` // text / post / image / share_chat / interactive / file /audio / media Content struct { Text string `json:"text"` } `json:"content"` } ) -func newFeishuTextPayload(text string) *FeishuPayload { - return &FeishuPayload{ +func newFeishuTextPayload(text string) FeishuPayload { + return FeishuPayload{ MsgType: "text", Content: struct { Text string `json:"text"` @@ -34,19 +36,8 @@ func newFeishuTextPayload(text string) *FeishuPayload { } } -// JSONPayload Marshals the FeishuPayload to json -func (f *FeishuPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(f, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - -var _ PayloadConvertor = &FeishuPayload{} - // Create implements PayloadConvertor Create method -func (f *FeishuPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (fc feishuConvertor) Create(p *api.CreatePayload) (FeishuPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() text := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) @@ -55,7 +46,7 @@ func (f *FeishuPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete implements PayloadConvertor Delete method -func (f *FeishuPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (fc feishuConvertor) Delete(p *api.DeletePayload) (FeishuPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() text := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) @@ -64,14 +55,14 @@ func (f *FeishuPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork implements PayloadConvertor Fork method -func (f *FeishuPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (fc feishuConvertor) Fork(p *api.ForkPayload) (FeishuPayload, error) { text := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) return newFeishuTextPayload(text), nil } // Push implements PayloadConvertor Push method -func (f *FeishuPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (fc feishuConvertor) Push(p *api.PushPayload) (FeishuPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -96,48 +87,40 @@ func (f *FeishuPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (f *FeishuPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (fc feishuConvertor) Issue(p *api.IssuePayload) (FeishuPayload, error) { title, link, by, operator, result, assignees := getIssuesInfo(p) - var res api.Payloader if assignees != "" { if p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueMilestoned { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.Issue.Body)) - } else { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.Issue.Body)) + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.Issue.Body)), nil } - } else { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.Issue.Body)) + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.Issue.Body)), nil } - return res, nil + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.Issue.Body)), nil } // IssueComment implements PayloadConvertor IssueComment method -func (f *FeishuPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (fc feishuConvertor) IssueComment(p *api.IssueCommentPayload) (FeishuPayload, error) { title, link, by, operator := getIssuesCommentInfo(p) return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.Comment.Body)), nil } // PullRequest implements PayloadConvertor PullRequest method -func (f *FeishuPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (fc feishuConvertor) PullRequest(p *api.PullRequestPayload) (FeishuPayload, error) { title, link, by, operator, result, assignees := getPullRequestInfo(p) - var res api.Payloader if assignees != "" { if p.Action == api.HookIssueAssigned || p.Action == api.HookIssueUnassigned || p.Action == api.HookIssueMilestoned { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.PullRequest.Body)) - } else { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.PullRequest.Body)) + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, result, assignees, p.PullRequest.Body)), nil } - } else { - res = newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.PullRequest.Body)) + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, assignees, p.PullRequest.Body)), nil } - return res, nil + return newFeishuTextPayload(fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", title, link, by, operator, p.PullRequest.Body)), nil } // Review implements PayloadConvertor Review method -func (f *FeishuPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (fc feishuConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (FeishuPayload, error) { action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return FeishuPayload{}, err } title := fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) @@ -147,7 +130,7 @@ func (f *FeishuPayload) Review(p *api.PullRequestPayload, event webhook_module.H } // Repository implements PayloadConvertor Repository method -func (f *FeishuPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (fc feishuConvertor) Repository(p *api.RepositoryPayload) (FeishuPayload, error) { var text string switch p.Action { case api.HookRepoCreated: @@ -158,24 +141,33 @@ func (f *FeishuPayload) Repository(p *api.RepositoryPayload) (api.Payloader, err return newFeishuTextPayload(text), nil } - return nil, nil + return FeishuPayload{}, nil } // Wiki implements PayloadConvertor Wiki method -func (f *FeishuPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (fc feishuConvertor) Wiki(p *api.WikiPayload) (FeishuPayload, error) { text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true) return newFeishuTextPayload(text), nil } // Release implements PayloadConvertor Release method -func (f *FeishuPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (fc feishuConvertor) Release(p *api.ReleasePayload) (FeishuPayload, error) { text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true) return newFeishuTextPayload(text), nil } -// GetFeishuPayload converts a ding talk webhook into a FeishuPayload -func GetFeishuPayload(p api.Payloader, event webhook_module.HookEventType, _ string) (api.Payloader, error) { - return convertPayloader(new(FeishuPayload), p, event) +func (fc feishuConvertor) Package(p *api.PackagePayload) (FeishuPayload, error) { + text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true) + + return newFeishuTextPayload(text), nil +} + +type feishuConvertor struct{} + +var _ payloadConvertor[FeishuPayload] = feishuConvertor{} + +func newFeishuRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + return newJSONRequest(feishuConvertor{}, w, t, true) } diff --git a/services/webhook/feishu_test.go b/services/webhook/feishu_test.go index a3182e82b09..ef18333fd4b 100644 --- a/services/webhook/feishu_test.go +++ b/services/webhook/feishu_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,187 +17,177 @@ import ( ) func TestFeishuPayload(t *testing.T) { + fc := feishuConvertor{} t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(FeishuPayload) - pl, err := d.Create(p) + pl, err := fc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, `[test/repo] branch test created`, pl.(*FeishuPayload).Content.Text) + assert.Equal(t, `[test/repo] branch test created`, pl.Content.Text) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(FeishuPayload) - pl, err := d.Delete(p) + pl, err := fc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, `[test/repo] branch test deleted`, pl.(*FeishuPayload).Content.Text) + assert.Equal(t, `[test/repo] branch test deleted`, pl.Content.Text) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(FeishuPayload) - pl, err := d.Fork(p) + pl, err := fc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, `test/repo2 is forked to test/repo`, pl.(*FeishuPayload).Content.Text) + assert.Equal(t, `test/repo2 is forked to test/repo`, pl.Content.Text) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(FeishuPayload) - pl, err := d.Push(p) + pl, err := fc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Content.Text) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(FeishuPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := fc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[Issue-test/repo #2]: opened\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[Issue-test/repo #2]: opened\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.Content.Text) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = fc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[Issue-test/repo #2]: closed\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[Issue-test/repo #2]: closed\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\nAssignees: user1\n\nissue body", pl.Content.Text) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(FeishuPayload) - pl, err := d.IssueComment(p) + pl, err := fc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[Comment-test/repo #2]: created\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\n\nmore info needed", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[Comment-test/repo #2]: created\ncrash\nhttp://localhost:3000/test/repo/issues/2\nIssue by user1\nOperator: user1\n\nmore info needed", pl.Content.Text) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(FeishuPayload) - pl, err := d.PullRequest(p) + pl, err := fc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[PullRequest-test/repo #12]: opened\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\nAssignees: user1\n\nfixes bug #2", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[PullRequest-test/repo #12]: opened\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\nAssignees: user1\n\nfixes bug #2", pl.Content.Text) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(FeishuPayload) - pl, err := d.IssueComment(p) + pl, err := fc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[Comment-test/repo #12]: created\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\n\nchanges requested", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[Comment-test/repo #12]: created\nFix bug\nhttp://localhost:3000/test/repo/pulls/12\nPullRequest by user1\nOperator: user1\n\nchanges requested", pl.Content.Text) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(FeishuPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := fc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] Pull request review approved : #12 Fix bug\r\n\r\ngood job", pl.Content.Text) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(FeishuPayload) - pl, err := d.Repository(p) + pl, err := fc.Repository(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] Repository created", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] Repository created", pl.Content.Text) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := fc.Package(p) + require.NoError(t, err) + + assert.Equal(t, "Package created: GiteaContainer:latest by user1", pl.Content.Text) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(FeishuPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := fc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment) by user1", pl.Content.Text) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = fc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment) by user1", pl.Content.Text) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = fc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] Wiki page 'index' deleted by user1", pl.Content.Text) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(FeishuPayload) - pl, err := d.Release(p) + pl, err := fc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.(*FeishuPayload).Content.Text) + assert.Equal(t, "[test/repo] Release created: v1.0 by user1", pl.Content.Text) }) } func TestFeishuJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(FeishuPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &FeishuPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.FEISHU, + URL: "https://feishu.example.com/", + Meta: `{}`, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newFeishuRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://feishu.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body FeishuPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[test/repo:test] \r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\r\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", body.Content.Text) } diff --git a/services/webhook/general.go b/services/webhook/general.go index 986467bc99d..69b944f4bd5 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -293,6 +293,24 @@ func getIssueCommentPayloadInfo(p *api.IssueCommentPayload, linkFormatter linkFo return text, issueTitle, color } +func getPackagePayloadInfo(p *api.PackagePayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { + refLink := linkFormatter(p.Package.HTMLURL, p.Package.Name+":"+p.Package.Version) + + switch p.Action { + case api.HookPackageCreated: + text = fmt.Sprintf("Package created: %s", refLink) + color = greenColor + case api.HookPackageDeleted: + text = fmt.Sprintf("Package deleted: %s", refLink) + color = redColor + } + if withSender { + text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)) + } + + return text, color +} + // ToHook convert models.Webhook to api.Hook // This function is not part of the convert package to prevent an import cycle func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index 64bd72f5a05..41bac3fd04d 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -240,7 +240,7 @@ func pullReleaseTestPayload() *api.ReleasePayload { Target: "master", Title: "First stable release", Note: "Note of first stable release", - URL: "http://localhost:3000/api/v1/repos/test/repo/releases/2", + HTMLURL: "http://localhost:3000/test/repo/releases/tag/v1.0", }, } } @@ -303,6 +303,36 @@ func repositoryTestPayload() *api.RepositoryPayload { } } +func packageTestPayload() *api.PackagePayload { + return &api.PackagePayload{ + Action: api.HookPackageCreated, + Sender: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + Repository: nil, + Organization: &api.User{ + UserName: "org1", + AvatarURL: "http://localhost:3000/org1/avatar", + }, + Package: &api.Package{ + Owner: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + Repository: nil, + Creator: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + Type: "container", + Name: "GiteaContainer", + Version: "latest", + HTMLURL: "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", + }, + } +} + func TestGetIssuesPayloadInfo(t *testing.T) { p := issueTestPayload() diff --git a/services/webhook/main_test.go b/services/webhook/main_test.go index cd34c02b5c5..756b9db2308 100644 --- a/services/webhook/main_test.go +++ b/services/webhook/main_test.go @@ -4,7 +4,6 @@ package webhook import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" @@ -19,7 +18,6 @@ func TestMain(m *testing.M) { // for tests, allow only loopback IPs setting.Webhook.AllowedHostList = hostmatcher.MatchBuiltinLoopback unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), SetUp: func() error { setting.LoadQueueSettings() return Init() diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index df97b43b64c..0329804a8bd 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -4,11 +4,12 @@ package webhook import ( + "bytes" + "context" "crypto/sha1" "encoding/hex" - "errors" "fmt" - "html" + "net/http" "net/url" "regexp" "strings" @@ -23,6 +24,37 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" ) +func newMatrixRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + meta := &MatrixMeta{} + if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { + return nil, nil, fmt.Errorf("GetMatrixPayload meta json: %w", err) + } + mc := matrixConvertor{ + MsgType: messageTypeText[meta.MessageType], + } + payload, err := newPayload(mc, []byte(t.PayloadContent), t.EventType) + if err != nil { + return nil, nil, err + } + + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, nil, err + } + + txnID, err := getMatrixTxnID(body) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequest(http.MethodPut, w.URL+"/"+txnID, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + + return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) // likely useless, but has always been sent historially +} + const matrixPayloadSizeLimit = 1024 * 64 // MatrixMeta contains the Matrix metadata @@ -46,8 +78,6 @@ func GetMatrixHook(w *webhook_model.Webhook) *MatrixMeta { return s } -var _ PayloadConvertor = &MatrixPayload{} - // MatrixPayload contains payload for a Matrix room type MatrixPayload struct { Body string `json:"body"` @@ -57,90 +87,79 @@ type MatrixPayload struct { Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"` } -// JSONPayload Marshals the MatrixPayload to json -func (m *MatrixPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} +var _ payloadConvertor[MatrixPayload] = matrixConvertor{} -// MatrixLinkFormatter creates a link compatible with Matrix -func MatrixLinkFormatter(url, text string) string { - return fmt.Sprintf(`%s`, html.EscapeString(url), html.EscapeString(text)) +type matrixConvertor struct { + MsgType string } -// MatrixLinkToRef Matrix-formatter link to a repo ref -func MatrixLinkToRef(repoURL, ref string) string { - refName := git.RefName(ref).ShortName() - switch { - case strings.HasPrefix(ref, git.BranchPrefix): - return MatrixLinkFormatter(repoURL+"/src/branch/"+util.PathEscapeSegments(refName), refName) - case strings.HasPrefix(ref, git.TagPrefix): - return MatrixLinkFormatter(repoURL+"/src/tag/"+util.PathEscapeSegments(refName), refName) - default: - return MatrixLinkFormatter(repoURL+"/src/commit/"+util.PathEscapeSegments(refName), refName) - } +func (m matrixConvertor) newPayload(text string, commits ...*api.PayloadCommit) (MatrixPayload, error) { + return MatrixPayload{ + Body: getMessageBody(text), + MsgType: m.MsgType, + Format: "org.matrix.custom.html", + FormattedBody: text, + Commits: commits, + }, nil } -// Create implements PayloadConvertor Create method -func (m *MatrixPayload) Create(p *api.CreatePayload) (api.Payloader, error) { - repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) +// Create implements payloadConvertor Create method +func (m matrixConvertor) Create(p *api.CreatePayload) (MatrixPayload, error) { + repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) refLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref) text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } // Delete composes Matrix payload for delete a branch or tag. -func (m *MatrixPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (m matrixConvertor) Delete(p *api.DeletePayload) (MatrixPayload, error) { refName := git.RefName(p.Ref).ShortName() - repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) + repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } // Fork composes Matrix payload for forked by a repository. -func (m *MatrixPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { - baseLink := MatrixLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) - forkLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) +func (m matrixConvertor) Fork(p *api.ForkPayload) (MatrixPayload, error) { + baseLink := htmlLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) + forkLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Issue implements PayloadConvertor Issue method -func (m *MatrixPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { - text, _, _, _ := getIssuesPayloadInfo(p, MatrixLinkFormatter, true) +// Issue implements payloadConvertor Issue method +func (m matrixConvertor) Issue(p *api.IssuePayload) (MatrixPayload, error) { + text, _, _, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, true) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// IssueComment implements PayloadConvertor IssueComment method -func (m *MatrixPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { - text, _, _ := getIssueCommentPayloadInfo(p, MatrixLinkFormatter, true) +// IssueComment implements payloadConvertor IssueComment method +func (m matrixConvertor) IssueComment(p *api.IssueCommentPayload) (MatrixPayload, error) { + text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, true) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Wiki implements PayloadConvertor Wiki method -func (m *MatrixPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { - text, _, _ := getWikiPayloadInfo(p, MatrixLinkFormatter, true) +// Wiki implements payloadConvertor Wiki method +func (m matrixConvertor) Wiki(p *api.WikiPayload) (MatrixPayload, error) { + text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, true) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Release implements PayloadConvertor Release method -func (m *MatrixPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { - text, _ := getReleasePayloadInfo(p, MatrixLinkFormatter, true) +// Release implements payloadConvertor Release method +func (m matrixConvertor) Release(p *api.ReleasePayload) (MatrixPayload, error) { + text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, true) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Push implements PayloadConvertor Push method -func (m *MatrixPayload) Push(p *api.PushPayload) (api.Payloader, error) { +// Push implements payloadConvertor Push method +func (m matrixConvertor) Push(p *api.PushPayload) (MatrixPayload, error) { var commitDesc string if p.TotalCommits == 1 { @@ -149,13 +168,13 @@ func (m *MatrixPayload) Push(p *api.PushPayload) (api.Payloader, error) { commitDesc = fmt.Sprintf("%d commits", p.TotalCommits) } - repoLink := MatrixLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) + repoLink := htmlLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) branchLink := MatrixLinkToRef(p.Repo.HTMLURL, p.Ref) text := fmt.Sprintf("[%s] %s pushed %s to %s:
", repoLink, p.Pusher.UserName, commitDesc, branchLink) // for each commit, generate a new line text for i, commit := range p.Commits { - text += fmt.Sprintf("%s: %s - %s", MatrixLinkFormatter(commit.URL, commit.ID[:7]), commit.Message, commit.Author.Name) + text += fmt.Sprintf("%s: %s - %s", htmlLinkFormatter(commit.URL, commit.ID[:7]), commit.Message, commit.Author.Name) // add linebreak to each commit but the last if i < len(p.Commits)-1 { text += "
" @@ -163,41 +182,41 @@ func (m *MatrixPayload) Push(p *api.PushPayload) (api.Payloader, error) { } - return getMatrixPayload(text, p.Commits, m.MsgType), nil + return m.newPayload(text, p.Commits...) } -// PullRequest implements PayloadConvertor PullRequest method -func (m *MatrixPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { - text, _, _, _ := getPullRequestPayloadInfo(p, MatrixLinkFormatter, true) +// PullRequest implements payloadConvertor PullRequest method +func (m matrixConvertor) PullRequest(p *api.PullRequestPayload) (MatrixPayload, error) { + text, _, _, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, true) - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Review implements PayloadConvertor Review method -func (m *MatrixPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { - senderLink := MatrixLinkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName) +// Review implements payloadConvertor Review method +func (m matrixConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MatrixPayload, error) { + senderLink := htmlLinkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName) title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title) - titleLink := MatrixLinkFormatter(p.PullRequest.URL, title) - repoLink := MatrixLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName) + titleLink := htmlLinkFormatter(p.PullRequest.HTMLURL, title) + repoLink := htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName) var text string switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return MatrixPayload{}, err } text = fmt.Sprintf("[%s] Pull request review %s: %s by %s", repoLink, action, titleLink, senderLink) } - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// Repository implements PayloadConvertor Repository method -func (m *MatrixPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { - senderLink := MatrixLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) - repoLink := MatrixLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName) +// Repository implements payloadConvertor Repository method +func (m matrixConvertor) Repository(p *api.RepositoryPayload) (MatrixPayload, error) { + senderLink := htmlLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) + repoLink := htmlLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName) var text string switch p.Action { @@ -206,32 +225,22 @@ func (m *MatrixPayload) Repository(p *api.RepositoryPayload) (api.Payloader, err case api.HookRepoDeleted: text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink) } - - return getMatrixPayload(text, nil, m.MsgType), nil + return m.newPayload(text) } -// GetMatrixPayload converts a Matrix webhook into a MatrixPayload -func GetMatrixPayload(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) { - s := new(MatrixPayload) +func (m matrixConvertor) Package(p *api.PackagePayload) (MatrixPayload, error) { + senderLink := htmlLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) + packageLink := htmlLinkFormatter(p.Package.HTMLURL, p.Package.Name) + var text string - matrix := &MatrixMeta{} - if err := json.Unmarshal([]byte(meta), &matrix); err != nil { - return s, errors.New("GetMatrixPayload meta json:" + err.Error()) + switch p.Action { + case api.HookPackageCreated: + text = fmt.Sprintf("[%s] Package published by %s", packageLink, senderLink) + case api.HookPackageDeleted: + text = fmt.Sprintf("[%s] Package deleted by %s", packageLink, senderLink) } - s.MsgType = messageTypeText[matrix.MessageType] - - return convertPayloader(s, p, event) -} - -func getMatrixPayload(text string, commits []*api.PayloadCommit, msgType string) *MatrixPayload { - p := MatrixPayload{} - p.FormattedBody = text - p.Body = getMessageBody(text) - p.Format = "org.matrix.custom.html" - p.MsgType = msgType - p.Commits = commits - return &p + return m.newPayload(text) } var urlRegex = regexp.MustCompile(`]*?href="([^">]*?)">(.*?)`) @@ -256,3 +265,16 @@ func getMatrixTxnID(payload []byte) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } + +// MatrixLinkToRef Matrix-formatter link to a repo ref +func MatrixLinkToRef(repoURL, ref string) string { + refName := git.RefName(ref).ShortName() + switch { + case strings.HasPrefix(ref, git.BranchPrefix): + return htmlLinkFormatter(repoURL+"/src/branch/"+util.PathEscapeSegments(refName), refName) + case strings.HasPrefix(ref, git.TagPrefix): + return htmlLinkFormatter(repoURL+"/src/tag/"+util.PathEscapeSegments(refName), refName) + default: + return htmlLinkFormatter(repoURL+"/src/commit/"+util.PathEscapeSegments(refName), refName) + } +} diff --git a/services/webhook/matrix_test.go b/services/webhook/matrix_test.go index 8c710942280..058f8e3c5f9 100644 --- a/services/webhook/matrix_test.go +++ b/services/webhook/matrix_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,204 +17,213 @@ import ( ) func TestMatrixPayload(t *testing.T) { + mc := matrixConvertor{ + MsgType: "m.text", + } + t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(MatrixPayload) - pl, err := d.Create(p) + pl, err := mc.Create(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):[test](http://localhost:3000/test/repo/src/branch/test)] branch created by user1", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo:test] branch created by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):[test](http://localhost:3000/test/repo/src/branch/test)] branch created by user1", pl.Body) + assert.Equal(t, `[test/repo:test] branch created by user1`, pl.FormattedBody) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(MatrixPayload) - pl, err := d.Delete(p) + pl, err := mc.Delete(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):test] branch deleted by user1", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo:test] branch deleted by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo):test] branch deleted by user1", pl.Body) + assert.Equal(t, `[test/repo:test] branch deleted by user1`, pl.FormattedBody) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(MatrixPayload) - pl, err := d.Fork(p) + pl, err := mc.Fork(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[test/repo2](http://localhost:3000/test/repo2) is forked to [test/repo](http://localhost:3000/test/repo)", pl.(*MatrixPayload).Body) - assert.Equal(t, `test/repo2 is forked to test/repo`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[test/repo2](http://localhost:3000/test/repo2) is forked to [test/repo](http://localhost:3000/test/repo)", pl.Body) + assert.Equal(t, `test/repo2 is forked to test/repo`, pl.FormattedBody) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(MatrixPayload) - pl, err := d.Push(p) + pl, err := mc.Push(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] user1 pushed 2 commits to test:
2020558: commit message - user1
2020558: commit message - user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1", pl.Body) + assert.Equal(t, `[test/repo] user1 pushed 2 commits to test:
2020558: commit message - user1
2020558: commit message - user1`, pl.FormattedBody) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(MatrixPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := mc.Issue(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue opened: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Issue opened: #2 crash by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue opened: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Issue opened: #2 crash by user1`, pl.FormattedBody) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = mc.Issue(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue closed: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Issue closed: #2 crash by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Issue closed: [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Issue closed: #2 crash by user1`, pl.FormattedBody) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(MatrixPayload) - pl, err := d.IssueComment(p) + pl, err := mc.IssueComment(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on issue [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] New comment on issue #2 crash by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on issue [#2 crash](http://localhost:3000/test/repo/issues/2) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] New comment on issue #2 crash by user1`, pl.FormattedBody) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(MatrixPayload) - pl, err := d.PullRequest(p) + pl, err := mc.PullRequest(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request opened: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Pull request opened: #12 Fix bug by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request opened: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Pull request opened: #12 Fix bug by user1`, pl.FormattedBody) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(MatrixPayload) - pl, err := d.IssueComment(p) + pl, err := mc.IssueComment(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on pull request [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] New comment on pull request #12 Fix bug by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New comment on pull request [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] New comment on pull request #12 Fix bug by user1`, pl.FormattedBody) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(MatrixPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := mc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug by user1`, pl.FormattedBody) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(MatrixPayload) - pl, err := d.Repository(p) + pl, err := mc.Repository(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, `[[test/repo](http://localhost:3000/test/repo)] Repository created by [user1](https://try.gitea.io/user1)`, pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Repository created by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, `[[test/repo](http://localhost:3000/test/repo)] Repository created by [user1](https://try.gitea.io/user1)`, pl.Body) + assert.Equal(t, `[test/repo] Repository created by user1`, pl.FormattedBody) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := mc.Package(p) + require.NoError(t, err) + require.NotNil(t, pl) + + assert.Equal(t, `[[GiteaContainer](http://localhost:3000/user1/-/packages/container/GiteaContainer/latest)] Package published by [user1](https://try.gitea.io/user1)`, pl.Body) + assert.Equal(t, `[GiteaContainer] Package published by user1`, pl.FormattedBody) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(MatrixPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := mc.Wiki(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New wiki page '[index](http://localhost:3000/test/repo/wiki/index)' (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] New wiki page '[index](http://localhost:3000/test/repo/wiki/index)' (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.FormattedBody) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = mc.Wiki(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' edited (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' edited (Wiki change comment) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.FormattedBody) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = mc.Wiki(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' deleted by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Wiki page '[index](http://localhost:3000/test/repo/wiki/index)' deleted by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.FormattedBody) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(MatrixPayload) - pl, err := d.Release(p) + pl, err := mc.Release(p) require.NoError(t, err) require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Release created: [v1.0](http://localhost:3000/test/repo/releases/tag/v1.0) by [user1](https://try.gitea.io/user1)", pl.(*MatrixPayload).Body) - assert.Equal(t, `[test/repo] Release created: v1.0 by user1`, pl.(*MatrixPayload).FormattedBody) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] Release created: [v1.0](http://localhost:3000/test/repo/releases/tag/v1.0) by [user1](https://try.gitea.io/user1)", pl.Body) + assert.Equal(t, `[test/repo] Release created: v1.0 by user1`, pl.FormattedBody) }) } func TestMatrixJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(MatrixPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MatrixPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.MATRIX, + URL: "https://matrix.example.com/_matrix/client/r0/rooms/ROOM_ID/send/m.room.message", + Meta: `{"message_type":0}`, // text + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newMatrixRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "PUT", req.Method) + assert.Equal(t, "/_matrix/client/r0/rooms/ROOM_ID/send/m.room.message/6db5dc1e282529a8c162c7fe93dd2667494eeb51", req.URL.Path) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body MatrixPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1", body.Body) } func Test_getTxnID(t *testing.T) { diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 6ad58a6247f..99d01061844 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -4,12 +4,14 @@ package webhook import ( + "context" "fmt" + "net/http" "net/url" "strings" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -56,19 +58,8 @@ type ( } ) -// JSONPayload Marshals the MSTeamsPayload to json -func (m *MSTeamsPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - -var _ PayloadConvertor = &MSTeamsPayload{} - // Create implements PayloadConvertor Create method -func (m *MSTeamsPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (m msteamsConvertor) Create(p *api.CreatePayload) (MSTeamsPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) @@ -85,7 +76,7 @@ func (m *MSTeamsPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete implements PayloadConvertor Delete method -func (m *MSTeamsPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (m msteamsConvertor) Delete(p *api.DeletePayload) (MSTeamsPayload, error) { // deleted tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) @@ -102,7 +93,7 @@ func (m *MSTeamsPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork implements PayloadConvertor Fork method -func (m *MSTeamsPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (m msteamsConvertor) Fork(p *api.ForkPayload) (MSTeamsPayload, error) { title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) return createMSTeamsPayload( @@ -117,7 +108,7 @@ func (m *MSTeamsPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { } // Push implements PayloadConvertor Push method -func (m *MSTeamsPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (m msteamsConvertor) Push(p *api.PushPayload) (MSTeamsPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -160,7 +151,7 @@ func (m *MSTeamsPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (m *MSTeamsPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (m msteamsConvertor) Issue(p *api.IssuePayload) (MSTeamsPayload, error) { title, _, attachmentText, color := getIssuesPayloadInfo(p, noneLinkFormatter, false) return createMSTeamsPayload( @@ -175,7 +166,7 @@ func (m *MSTeamsPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { } // IssueComment implements PayloadConvertor IssueComment method -func (m *MSTeamsPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (m msteamsConvertor) IssueComment(p *api.IssueCommentPayload) (MSTeamsPayload, error) { title, _, color := getIssueCommentPayloadInfo(p, noneLinkFormatter, false) return createMSTeamsPayload( @@ -190,7 +181,7 @@ func (m *MSTeamsPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader } // PullRequest implements PayloadConvertor PullRequest method -func (m *MSTeamsPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (m msteamsConvertor) PullRequest(p *api.PullRequestPayload) (MSTeamsPayload, error) { title, _, attachmentText, color := getPullRequestPayloadInfo(p, noneLinkFormatter, false) return createMSTeamsPayload( @@ -205,14 +196,14 @@ func (m *MSTeamsPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, } // Review implements PayloadConvertor Review method -func (m *MSTeamsPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (m msteamsConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (MSTeamsPayload, error) { var text, title string var color int switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return MSTeamsPayload{}, err } title = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) @@ -242,7 +233,7 @@ func (m *MSTeamsPayload) Review(p *api.PullRequestPayload, event webhook_module. } // Repository implements PayloadConvertor Repository method -func (m *MSTeamsPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (m msteamsConvertor) Repository(p *api.RepositoryPayload) (MSTeamsPayload, error) { var title, url string var color int switch p.Action { @@ -267,7 +258,7 @@ func (m *MSTeamsPayload) Repository(p *api.RepositoryPayload) (api.Payloader, er } // Wiki implements PayloadConvertor Wiki method -func (m *MSTeamsPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (m msteamsConvertor) Wiki(p *api.WikiPayload) (MSTeamsPayload, error) { title, color, _ := getWikiPayloadInfo(p, noneLinkFormatter, false) return createMSTeamsPayload( @@ -282,7 +273,7 @@ func (m *MSTeamsPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { } // Release implements PayloadConvertor Release method -func (m *MSTeamsPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (m msteamsConvertor) Release(p *api.ReleasePayload) (MSTeamsPayload, error) { title, color := getReleasePayloadInfo(p, noneLinkFormatter, false) return createMSTeamsPayload( @@ -290,29 +281,39 @@ func (m *MSTeamsPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { p.Sender, title, "", - p.Release.URL, + p.Release.HTMLURL, color, &MSTeamsFact{"Tag:", p.Release.TagName}, ), nil } -// GetMSTeamsPayload converts a MSTeams webhook into a MSTeamsPayload -func GetMSTeamsPayload(p api.Payloader, event webhook_module.HookEventType, _ string) (api.Payloader, error) { - return convertPayloader(new(MSTeamsPayload), p, event) +func (m msteamsConvertor) Package(p *api.PackagePayload) (MSTeamsPayload, error) { + title, color := getPackagePayloadInfo(p, noneLinkFormatter, false) + + return createMSTeamsPayload( + p.Repository, + p.Sender, + title, + "", + p.Package.HTMLURL, + color, + &MSTeamsFact{"Package:", p.Package.Name}, + ), nil } -func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) *MSTeamsPayload { - facts := []MSTeamsFact{ - { +func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload { + facts := make([]MSTeamsFact, 0, 2) + if r != nil { + facts = append(facts, MSTeamsFact{ Name: "Repository:", Value: r.FullName, - }, + }) } if fact != nil { facts = append(facts, *fact) } - return &MSTeamsPayload{ + return MSTeamsPayload{ Type: "MessageCard", Context: "https://schema.org/extensions", ThemeColor: fmt.Sprintf("%x", color), @@ -341,3 +342,11 @@ func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTar }, } } + +type msteamsConvertor struct{} + +var _ payloadConvertor[MSTeamsPayload] = msteamsConvertor{} + +func newMSTeamsRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + return newJSONRequest(msteamsConvertor{}, w, t, true) +} diff --git a/services/webhook/msteams_test.go b/services/webhook/msteams_test.go index 4f378713cc7..01e08b918e5 100644 --- a/services/webhook/msteams_test.go +++ b/services/webhook/msteams_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,22 +17,20 @@ import ( ) func TestMSTeamsPayload(t *testing.T) { + mc := msteamsConvertor{} t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Create(p) + pl, err := mc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] branch test created", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] branch test created", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] branch test created", pl.Title) + assert.Equal(t, "[test/repo] branch test created", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repo.FullName, fact.Value) } else if fact.Name == "branch:" { @@ -38,27 +39,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Delete(p) + pl, err := mc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] branch test deleted", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] branch test deleted", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] branch test deleted", pl.Title) + assert.Equal(t, "[test/repo] branch test deleted", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repo.FullName, fact.Value) } else if fact.Name == "branch:" { @@ -67,27 +65,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Fork(p) + pl, err := mc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "test/repo2 is forked to test/repo", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "test/repo2 is forked to test/repo", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "test/repo2 is forked to test/repo", pl.Title) + assert.Equal(t, "test/repo2 is forked to test/repo", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repo.FullName, fact.Value) } else if fact.Name == "Forkee:" { @@ -96,27 +91,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Push(p) + pl, err := mc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo:test] 2 new commits", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo:test] 2 new commits", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo:test] 2 new commits", pl.Title) + assert.Equal(t, "[test/repo:test] 2 new commits", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1\n\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778) commit message - user1", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repo.FullName, fact.Value) } else if fact.Name == "Commit count:" { @@ -125,28 +117,25 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/src/test", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(MSTeamsPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := mc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "issue body", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Title) + assert.Equal(t, "[test/repo] Issue opened: #2 crash", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "issue body", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Issue #:" { @@ -155,23 +144,21 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = mc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Title) + assert.Equal(t, "[test/repo] Issue closed: #2 crash", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Issue #:" { @@ -180,27 +167,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2", pl.PotentialAction[0].Targets[0].URI) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(MSTeamsPayload) - pl, err := d.IssueComment(p) + pl, err := mc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "more info needed", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Title) + assert.Equal(t, "[test/repo] New comment on issue #2 crash", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "more info needed", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Issue #:" { @@ -209,27 +193,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/issues/2#issuecomment-4", pl.PotentialAction[0].Targets[0].URI) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(MSTeamsPayload) - pl, err := d.PullRequest(p) + pl, err := mc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "fixes bug #2", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Title) + assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "fixes bug #2", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Pull request #:" { @@ -238,27 +219,24 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(MSTeamsPayload) - pl, err := d.IssueComment(p) + pl, err := mc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "changes requested", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Title) + assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "changes requested", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Issue #:" { @@ -267,28 +245,25 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12#issuecomment-4", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(MSTeamsPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := mc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "good job", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Title) + assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "good job", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Pull request #:" { @@ -297,128 +272,139 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/pulls/12", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Repository(p) + pl, err := mc.Repository(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Repository created", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Repository created", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 1) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Repository created", pl.Title) + assert.Equal(t, "[test/repo] Repository created", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 1) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo", pl.PotentialAction[0].Targets[0].URI) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := mc.Package(p) + require.NoError(t, err) + + assert.Equal(t, "Package created: GiteaContainer:latest", pl.Title) + assert.Equal(t, "Package created: GiteaContainer:latest", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 1) + for _, fact := range pl.Sections[0].Facts { + if fact.Name == "Package:" { + assert.Equal(t, p.Package.Name, fact.Value) + } else { + t.Fail() + } + } + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/user1/-/packages/container/GiteaContainer/latest", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(MSTeamsPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := mc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Title) + assert.Equal(t, "[test/repo] New wiki page 'index' (Wiki change comment)", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = mc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Equal(t, "", pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Title) + assert.Equal(t, "[test/repo] Wiki page 'index' edited (Wiki change comment)", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Equal(t, "", pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = mc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Title) + assert.Equal(t, "[test/repo] Wiki page 'index' deleted", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/wiki/index", pl.PotentialAction[0].Targets[0].URI) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(MSTeamsPayload) - pl, err := d.Release(p) + pl, err := mc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - - assert.Equal(t, "[test/repo] Release created: v1.0", pl.(*MSTeamsPayload).Title) - assert.Equal(t, "[test/repo] Release created: v1.0", pl.(*MSTeamsPayload).Summary) - assert.Len(t, pl.(*MSTeamsPayload).Sections, 1) - assert.Equal(t, "user1", pl.(*MSTeamsPayload).Sections[0].ActivitySubtitle) - assert.Empty(t, pl.(*MSTeamsPayload).Sections[0].Text) - assert.Len(t, pl.(*MSTeamsPayload).Sections[0].Facts, 2) - for _, fact := range pl.(*MSTeamsPayload).Sections[0].Facts { + + assert.Equal(t, "[test/repo] Release created: v1.0", pl.Title) + assert.Equal(t, "[test/repo] Release created: v1.0", pl.Summary) + assert.Len(t, pl.Sections, 1) + assert.Equal(t, "user1", pl.Sections[0].ActivitySubtitle) + assert.Empty(t, pl.Sections[0].Text) + assert.Len(t, pl.Sections[0].Facts, 2) + for _, fact := range pl.Sections[0].Facts { if fact.Name == "Repository:" { assert.Equal(t, p.Repository.FullName, fact.Value) } else if fact.Name == "Tag:" { @@ -427,21 +413,43 @@ func TestMSTeamsPayload(t *testing.T) { t.Fail() } } - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction, 1) - assert.Len(t, pl.(*MSTeamsPayload).PotentialAction[0].Targets, 1) - assert.Equal(t, "http://localhost:3000/api/v1/repos/test/repo/releases/2", pl.(*MSTeamsPayload).PotentialAction[0].Targets[0].URI) + assert.Len(t, pl.PotentialAction, 1) + assert.Len(t, pl.PotentialAction[0].Targets, 1) + assert.Equal(t, "http://localhost:3000/test/repo/releases/tag/v1.0", pl.PotentialAction[0].Targets[0].URI) }) } func TestMSTeamsJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(MSTeamsPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &MSTeamsPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.MSTEAMS, + URL: "https://msteams.example.com/", + Meta: ``, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newMSTeamsRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://msteams.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body MSTeamsPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[test/repo:test] 2 new commits", body.Summary) } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 1ab14fd6a7e..587caf62ff3 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -67,7 +67,7 @@ func (m *webhookNotifier) IssueClearLabels(ctx context.Context, doer *user_model err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueLabel, &api.IssuePayload{ Action: api.HookIssueLabelCleared, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }) @@ -168,7 +168,7 @@ func (m *webhookNotifier) IssueChangeAssignee(ctx context.Context, doer *user_mo permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) apiIssue := &api.IssuePayload{ Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), } @@ -214,7 +214,7 @@ func (m *webhookNotifier) IssueChangeTitle(ctx context.Context, doer *user_model From: oldTitle, }, }, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }) @@ -250,7 +250,7 @@ func (m *webhookNotifier) IssueChangeStatus(ctx context.Context, doer *user_mode } else { apiIssue := &api.IssuePayload{ Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), CommitID: commitID, @@ -281,7 +281,7 @@ func (m *webhookNotifier) NewIssue(ctx context.Context, issue *issues_model.Issu if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssues, &api.IssuePayload{ Action: api.HookIssueOpened, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, issue.Poster, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, issue.Poster, nil), }); err != nil { @@ -349,7 +349,7 @@ func (m *webhookNotifier) IssueChangeContent(ctx context.Context, doer *user_mod From: oldContent, }, }, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }) @@ -384,7 +384,7 @@ func (m *webhookNotifier) UpdateComment(ctx context.Context, doer *user_model.Us permission, _ := access_model.GetUserRepoPermission(ctx, c.Issue.Repo, doer) if err := PrepareWebhooks(ctx, EventSource{Repository: c.Issue.Repo}, eventType, &api.IssueCommentPayload{ Action: api.HookIssueCommentEdited, - Issue: convert.ToAPIIssue(ctx, c.Issue), + Issue: convert.ToAPIIssue(ctx, doer, c.Issue), Comment: convert.ToAPIComment(ctx, c.Issue.Repo, c), Changes: &api.ChangesPayload{ Body: &api.ChangesFromPayload{ @@ -412,7 +412,7 @@ func (m *webhookNotifier) CreateIssueComment(ctx context.Context, doer *user_mod permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer) if err := PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, eventType, &api.IssueCommentPayload{ Action: api.HookIssueCommentCreated, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Comment: convert.ToAPIComment(ctx, repo, comment), Repository: convert.ToRepo(ctx, repo, permission), Sender: convert.ToUser(ctx, doer, nil), @@ -449,7 +449,7 @@ func (m *webhookNotifier) DeleteComment(ctx context.Context, doer *user_model.Us permission, _ := access_model.GetUserRepoPermission(ctx, comment.Issue.Repo, doer) if err := PrepareWebhooks(ctx, EventSource{Repository: comment.Issue.Repo}, eventType, &api.IssueCommentPayload{ Action: api.HookIssueCommentDeleted, - Issue: convert.ToAPIIssue(ctx, comment.Issue), + Issue: convert.ToAPIIssue(ctx, doer, comment.Issue), Comment: convert.ToAPIComment(ctx, comment.Issue.Repo, comment), Repository: convert.ToRepo(ctx, comment.Issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), @@ -533,7 +533,7 @@ func (m *webhookNotifier) IssueChangeLabels(ctx context.Context, doer *user_mode err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueLabel, &api.IssuePayload{ Action: api.HookIssueLabelUpdated, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }) @@ -575,7 +575,7 @@ func (m *webhookNotifier) IssueChangeMilestone(ctx context.Context, doer *user_m err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueMilestone, &api.IssuePayload{ Action: hookAction, Index: issue.Index, - Issue: convert.ToAPIIssue(ctx, issue), + Issue: convert.ToAPIIssue(ctx, doer, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, doer, nil), }) diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index e47e7d32850..7880d8b6066 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -4,7 +4,9 @@ package webhook import ( - "errors" + "context" + "fmt" + "net/http" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/json" @@ -38,80 +40,85 @@ func GetPackagistHook(w *webhook_model.Webhook) *PackagistMeta { return s } -// JSONPayload Marshals the PackagistPayload to json -func (f *PackagistPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(f, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - -var _ PayloadConvertor = &PackagistPayload{} - // Create implements PayloadConvertor Create method -func (f *PackagistPayload) Create(_ *api.CreatePayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Create(_ *api.CreatePayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Delete implements PayloadConvertor Delete method -func (f *PackagistPayload) Delete(_ *api.DeletePayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Delete(_ *api.DeletePayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Fork implements PayloadConvertor Fork method -func (f *PackagistPayload) Fork(_ *api.ForkPayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Fork(_ *api.ForkPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Push implements PayloadConvertor Push method -func (f *PackagistPayload) Push(_ *api.PushPayload) (api.Payloader, error) { - return f, nil +// https://packagist.org/about +func (pc packagistConvertor) Push(_ *api.PushPayload) (PackagistPayload, error) { + return PackagistPayload{ + PackagistRepository: struct { + URL string `json:"url"` + }{ + URL: pc.PackageURL, + }, + }, nil } // Issue implements PayloadConvertor Issue method -func (f *PackagistPayload) Issue(_ *api.IssuePayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Issue(_ *api.IssuePayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // IssueComment implements PayloadConvertor IssueComment method -func (f *PackagistPayload) IssueComment(_ *api.IssueCommentPayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) IssueComment(_ *api.IssueCommentPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // PullRequest implements PayloadConvertor PullRequest method -func (f *PackagistPayload) PullRequest(_ *api.PullRequestPayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) PullRequest(_ *api.PullRequestPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Review implements PayloadConvertor Review method -func (f *PackagistPayload) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Review(_ *api.PullRequestPayload, _ webhook_module.HookEventType) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Repository implements PayloadConvertor Repository method -func (f *PackagistPayload) Repository(_ *api.RepositoryPayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Repository(_ *api.RepositoryPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Wiki implements PayloadConvertor Wiki method -func (f *PackagistPayload) Wiki(_ *api.WikiPayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Wiki(_ *api.WikiPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } // Release implements PayloadConvertor Release method -func (f *PackagistPayload) Release(_ *api.ReleasePayload) (api.Payloader, error) { - return nil, nil +func (pc packagistConvertor) Release(_ *api.ReleasePayload) (PackagistPayload, error) { + return PackagistPayload{}, nil } -// GetPackagistPayload converts a packagist webhook into a PackagistPayload -func GetPackagistPayload(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) { - s := new(PackagistPayload) +func (pc packagistConvertor) Package(_ *api.PackagePayload) (PackagistPayload, error) { + return PackagistPayload{}, nil +} + +type packagistConvertor struct { + PackageURL string +} - packagist := &PackagistMeta{} - if err := json.Unmarshal([]byte(meta), &packagist); err != nil { - return s, errors.New("GetPackagistPayload meta json:" + err.Error()) +var _ payloadConvertor[PackagistPayload] = packagistConvertor{} + +func newPackagistRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + meta := &PackagistMeta{} + if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { + return nil, nil, fmt.Errorf("newpackagistRequest meta json: %w", err) + } + pc := packagistConvertor{ + PackageURL: meta.PackageURL, } - s.PackagistRepository.URL = packagist.PackageURL - return convertPayloader(s, p, event) + return newJSONRequest(pc, w, t, true) } diff --git a/services/webhook/packagist_test.go b/services/webhook/packagist_test.go index 932b56fe9b9..e9b0695baae 100644 --- a/services/webhook/packagist_test.go +++ b/services/webhook/packagist_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,146 +17,199 @@ import ( ) func TestPackagistPayload(t *testing.T) { + pc := packagistConvertor{ + PackageURL: "https://packagist.org/packages/example", + } t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(PackagistPayload) - pl, err := d.Create(p) + pl, err := pc.Create(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(PackagistPayload) - pl, err := d.Delete(p) + pl, err := pc.Delete(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(PackagistPayload) - pl, err := d.Fork(p) + pl, err := pc.Fork(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(PackagistPayload) - d.PackagistRepository.URL = "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN" - pl, err := d.Push(p) + pl, err := pc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &PackagistPayload{}, pl) - assert.Equal(t, "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", pl.(*PackagistPayload).PackagistRepository.URL) + assert.Equal(t, "https://packagist.org/packages/example", pl.PackagistRepository.URL) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(PackagistPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := pc.Issue(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = pc.Issue(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(PackagistPayload) - pl, err := d.IssueComment(p) + pl, err := pc.IssueComment(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(PackagistPayload) - pl, err := d.PullRequest(p) + pl, err := pc.PullRequest(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(PackagistPayload) - pl, err := d.IssueComment(p) + pl, err := pc.IssueComment(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(PackagistPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := pc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(PackagistPayload) - pl, err := d.Repository(p) + pl, err := pc.Repository(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := pc.Package(p) + require.NoError(t, err) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(PackagistPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := pc.Wiki(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = pc.Wiki(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = pc.Wiki(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(PackagistPayload) - pl, err := d.Release(p) + pl, err := pc.Release(p) require.NoError(t, err) - require.Nil(t, pl) + require.Equal(t, pl, PackagistPayload{}) }) } func TestPackagistJSONPayload(t *testing.T) { p := pushTestPayload() + data, err := p.JSONPayload() + require.NoError(t, err) - pl, err := new(PackagistPayload).Push(p) + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.PACKAGIST, + URL: "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", + Meta: `{"package_url":"https://packagist.org/packages/example"}`, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newPackagistRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &PackagistPayload{}, pl) - json, err := pl.JSONPayload() + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body PackagistPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "https://packagist.org/packages/example", body.PackagistRepository.URL) +} + +func TestPackagistEmptyPayload(t *testing.T) { + p := createTestPayload() + data, err := p.JSONPayload() require.NoError(t, err) - assert.NotEmpty(t, json) + + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.PACKAGIST, + URL: "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", + Meta: `{"package_url":"https://packagist.org/packages/example"}`, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventCreate, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newPackagistRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) + require.NoError(t, err) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://packagist.org/api/update-package?username=THEUSERNAME&apiToken=TOPSECRETAPITOKEN", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body PackagistPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "", body.PackagistRepository.URL) } diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index d53e65fa5ee..54a11a58682 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -4,55 +4,109 @@ package webhook import ( + "bytes" + "fmt" + "net/http" + + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) -// PayloadConvertor defines the interface to convert system webhook payload to external payload -type PayloadConvertor interface { - api.Payloader - Create(*api.CreatePayload) (api.Payloader, error) - Delete(*api.DeletePayload) (api.Payloader, error) - Fork(*api.ForkPayload) (api.Payloader, error) - Issue(*api.IssuePayload) (api.Payloader, error) - IssueComment(*api.IssueCommentPayload) (api.Payloader, error) - Push(*api.PushPayload) (api.Payloader, error) - PullRequest(*api.PullRequestPayload) (api.Payloader, error) - Review(*api.PullRequestPayload, webhook_module.HookEventType) (api.Payloader, error) - Repository(*api.RepositoryPayload) (api.Payloader, error) - Release(*api.ReleasePayload) (api.Payloader, error) - Wiki(*api.WikiPayload) (api.Payloader, error) +// payloadConvertor defines the interface to convert system payload to webhook payload +type payloadConvertor[T any] interface { + Create(*api.CreatePayload) (T, error) + Delete(*api.DeletePayload) (T, error) + Fork(*api.ForkPayload) (T, error) + Issue(*api.IssuePayload) (T, error) + IssueComment(*api.IssueCommentPayload) (T, error) + Push(*api.PushPayload) (T, error) + PullRequest(*api.PullRequestPayload) (T, error) + Review(*api.PullRequestPayload, webhook_module.HookEventType) (T, error) + Repository(*api.RepositoryPayload) (T, error) + Release(*api.ReleasePayload) (T, error) + Wiki(*api.WikiPayload) (T, error) + Package(*api.PackagePayload) (T, error) } -func convertPayloader(s PayloadConvertor, p api.Payloader, event webhook_module.HookEventType) (api.Payloader, error) { +func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (T, error) { + var p P + if err := json.Unmarshal(data, &p); err != nil { + var t T + return t, fmt.Errorf("could not unmarshal payload: %w", err) + } + return convert(p) +} + +func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module.HookEventType) (T, error) { switch event { case webhook_module.HookEventCreate: - return s.Create(p.(*api.CreatePayload)) + return convertUnmarshalledJSON(rc.Create, data) case webhook_module.HookEventDelete: - return s.Delete(p.(*api.DeletePayload)) + return convertUnmarshalledJSON(rc.Delete, data) case webhook_module.HookEventFork: - return s.Fork(p.(*api.ForkPayload)) + return convertUnmarshalledJSON(rc.Fork, data) case webhook_module.HookEventIssues, webhook_module.HookEventIssueAssign, webhook_module.HookEventIssueLabel, webhook_module.HookEventIssueMilestone: - return s.Issue(p.(*api.IssuePayload)) + return convertUnmarshalledJSON(rc.Issue, data) case webhook_module.HookEventIssueComment, webhook_module.HookEventPullRequestComment: - pl, ok := p.(*api.IssueCommentPayload) - if ok { - return s.IssueComment(pl) - } - return s.PullRequest(p.(*api.PullRequestPayload)) + // previous code sometimes sent s.PullRequest(p.(*api.PullRequestPayload)) + // however I couldn't find in notifier.go such a payload with an HookEvent***Comment event + + // History (most recent first): + // - refactored in https://github.com/go-gitea/gitea/pull/12310 + // - assertion added in https://github.com/go-gitea/gitea/pull/12046 + // - issue raised in https://github.com/go-gitea/gitea/issues/11940#issuecomment-645713996 + // > That's because for HookEventPullRequestComment event, some places use IssueCommentPayload and others use PullRequestPayload + + // In modules/actions/workflows.go:183 the type assertion is always payload.(*api.IssueCommentPayload) + return convertUnmarshalledJSON(rc.IssueComment, data) case webhook_module.HookEventPush: - return s.Push(p.(*api.PushPayload)) + return convertUnmarshalledJSON(rc.Push, data) case webhook_module.HookEventPullRequest, webhook_module.HookEventPullRequestAssign, webhook_module.HookEventPullRequestLabel, webhook_module.HookEventPullRequestMilestone, webhook_module.HookEventPullRequestSync, webhook_module.HookEventPullRequestReviewRequest: - return s.PullRequest(p.(*api.PullRequestPayload)) + return convertUnmarshalledJSON(rc.PullRequest, data) case webhook_module.HookEventPullRequestReviewApproved, webhook_module.HookEventPullRequestReviewRejected, webhook_module.HookEventPullRequestReviewComment: - return s.Review(p.(*api.PullRequestPayload), event) + return convertUnmarshalledJSON(func(p *api.PullRequestPayload) (T, error) { + return rc.Review(p, event) + }, data) case webhook_module.HookEventRepository: - return s.Repository(p.(*api.RepositoryPayload)) + return convertUnmarshalledJSON(rc.Repository, data) case webhook_module.HookEventRelease: - return s.Release(p.(*api.ReleasePayload)) + return convertUnmarshalledJSON(rc.Release, data) case webhook_module.HookEventWiki: - return s.Wiki(p.(*api.WikiPayload)) + return convertUnmarshalledJSON(rc.Wiki, data) + case webhook_module.HookEventPackage: + return convertUnmarshalledJSON(rc.Package, data) + } + var t T + return t, fmt.Errorf("newPayload unsupported event: %s", event) +} + +func newJSONRequest[T any](pc payloadConvertor[T], w *webhook_model.Webhook, t *webhook_model.HookTask, withDefaultHeaders bool) (*http.Request, []byte, error) { + payload, err := newPayload(pc, []byte(t.PayloadContent), t.EventType) + if err != nil { + return nil, nil, err + } + + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, nil, err + } + + method := w.HTTPMethod + if method == "" { + method = http.MethodPost + } + + req, err := http.NewRequest(method, w.URL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + + if withDefaultHeaders { + return req, body, addDefaultHeaders(req, []byte(w.Secret), t, body) } - return s, nil + return req, body, nil } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 0cb27bb3dd6..ba8bac27d97 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -4,8 +4,9 @@ package webhook import ( - "errors" + "context" "fmt" + "net/http" "regexp" "strings" @@ -39,7 +40,6 @@ func GetSlackHook(w *webhook_model.Webhook) *SlackMeta { type SlackPayload struct { Channel string `json:"channel"` Text string `json:"text"` - Color string `json:"-"` Username string `json:"username"` IconURL string `json:"icon_url"` UnfurlLinks int `json:"unfurl_links"` @@ -56,15 +56,6 @@ type SlackAttachment struct { Text string `json:"text"` } -// JSONPayload Marshals the SlackPayload to json -func (s *SlackPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - // SlackTextFormatter replaces &, <, > with HTML characters // see: https://api.slack.com/docs/formatting func SlackTextFormatter(s string) string { @@ -92,15 +83,14 @@ func SlackLinkFormatter(url, text string) string { // SlackLinkToRef slack-formatter link to a repo ref func SlackLinkToRef(repoURL, ref string) string { + // FIXME: SHA1 hardcoded here url := git.RefURL(repoURL, ref) refName := git.RefName(ref).ShortName() return SlackLinkFormatter(url, refName) } -var _ PayloadConvertor = &SlackPayload{} - -// Create implements PayloadConvertor Create method -func (s *SlackPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +// Create implements payloadConvertor Create method +func (s slackConvertor) Create(p *api.CreatePayload) (SlackPayload, error) { repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref) text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName) @@ -109,7 +99,7 @@ func (s *SlackPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete composes Slack payload for delete a branch or tag. -func (s *SlackPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (s slackConvertor) Delete(p *api.DeletePayload) (SlackPayload, error) { refName := git.RefName(p.Ref).ShortName() repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName) @@ -118,7 +108,7 @@ func (s *SlackPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork composes Slack payload for forked by a repository. -func (s *SlackPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (s slackConvertor) Fork(p *api.ForkPayload) (SlackPayload, error) { baseLink := SlackLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) forkLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName) text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink) @@ -126,8 +116,8 @@ func (s *SlackPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { return s.createPayload(text, nil), nil } -// Issue implements PayloadConvertor Issue method -func (s *SlackPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +// Issue implements payloadConvertor Issue method +func (s slackConvertor) Issue(p *api.IssuePayload) (SlackPayload, error) { text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter, true) var attachments []SlackAttachment @@ -145,8 +135,8 @@ func (s *SlackPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { return s.createPayload(text, attachments), nil } -// IssueComment implements PayloadConvertor IssueComment method -func (s *SlackPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +// IssueComment implements payloadConvertor IssueComment method +func (s slackConvertor) IssueComment(p *api.IssueCommentPayload) (SlackPayload, error) { text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter, true) return s.createPayload(text, []SlackAttachment{{ @@ -157,22 +147,28 @@ func (s *SlackPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, }}), nil } -// Wiki implements PayloadConvertor Wiki method -func (s *SlackPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +// Wiki implements payloadConvertor Wiki method +func (s slackConvertor) Wiki(p *api.WikiPayload) (SlackPayload, error) { text, _, _ := getWikiPayloadInfo(p, SlackLinkFormatter, true) return s.createPayload(text, nil), nil } -// Release implements PayloadConvertor Release method -func (s *SlackPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +// Release implements payloadConvertor Release method +func (s slackConvertor) Release(p *api.ReleasePayload) (SlackPayload, error) { text, _ := getReleasePayloadInfo(p, SlackLinkFormatter, true) return s.createPayload(text, nil), nil } -// Push implements PayloadConvertor Push method -func (s *SlackPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (s slackConvertor) Package(p *api.PackagePayload) (SlackPayload, error) { + text, _ := getPackagePayloadInfo(p, SlackLinkFormatter, true) + + return s.createPayload(text, nil), nil +} + +// Push implements payloadConvertor Push method +func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) { // n new commits var ( commitDesc string @@ -212,8 +208,8 @@ func (s *SlackPayload) Push(p *api.PushPayload) (api.Payloader, error) { }}), nil } -// PullRequest implements PayloadConvertor PullRequest method -func (s *SlackPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +// PullRequest implements payloadConvertor PullRequest method +func (s slackConvertor) PullRequest(p *api.PullRequestPayload) (SlackPayload, error) { text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter, true) var attachments []SlackAttachment @@ -223,7 +219,7 @@ func (s *SlackPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, er attachments = append(attachments, SlackAttachment{ Color: fmt.Sprintf("%x", color), Title: issueTitle, - TitleLink: p.PullRequest.URL, + TitleLink: p.PullRequest.HTMLURL, Text: attachmentText, }) } @@ -231,8 +227,8 @@ func (s *SlackPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, er return s.createPayload(text, attachments), nil } -// Review implements PayloadConvertor Review method -func (s *SlackPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +// Review implements payloadConvertor Review method +func (s slackConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (SlackPayload, error) { senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title) titleLink := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index) @@ -243,7 +239,7 @@ func (s *SlackPayload) Review(p *api.PullRequestPayload, event webhook_module.Ho case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return SlackPayload{}, err } text = fmt.Sprintf("[%s] Pull request review %s: [%s](%s) by %s", repoLink, action, title, titleLink, senderLink) @@ -252,8 +248,8 @@ func (s *SlackPayload) Review(p *api.PullRequestPayload, event webhook_module.Ho return s.createPayload(text, nil), nil } -// Repository implements PayloadConvertor Repository method -func (s *SlackPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +// Repository implements payloadConvertor Repository method +func (s slackConvertor) Repository(p *api.RepositoryPayload) (SlackPayload, error) { senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName) var text string @@ -268,8 +264,8 @@ func (s *SlackPayload) Repository(p *api.RepositoryPayload) (api.Payloader, erro return s.createPayload(text, nil), nil } -func (s *SlackPayload) createPayload(text string, attachments []SlackAttachment) *SlackPayload { - return &SlackPayload{ +func (s slackConvertor) createPayload(text string, attachments []SlackAttachment) SlackPayload { + return SlackPayload{ Channel: s.Channel, Text: text, Username: s.Username, @@ -278,21 +274,27 @@ func (s *SlackPayload) createPayload(text string, attachments []SlackAttachment) } } -// GetSlackPayload converts a slack webhook into a SlackPayload -func GetSlackPayload(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) { - s := new(SlackPayload) - - slack := &SlackMeta{} - if err := json.Unmarshal([]byte(meta), &slack); err != nil { - return s, errors.New("GetSlackPayload meta json:" + err.Error()) - } +type slackConvertor struct { + Channel string + Username string + IconURL string + Color string +} - s.Channel = slack.Channel - s.Username = slack.Username - s.IconURL = slack.IconURL - s.Color = slack.Color +var _ payloadConvertor[SlackPayload] = slackConvertor{} - return convertPayloader(s, p, event) +func newSlackRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + meta := &SlackMeta{} + if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { + return nil, nil, fmt.Errorf("newSlackRequest meta json: %w", err) + } + sc := slackConvertor{ + Channel: meta.Channel, + Username: meta.Username, + IconURL: meta.IconURL, + Color: meta.Color, + } + return newJSONRequest(sc, w, t, true) } var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`) diff --git a/services/webhook/slack_test.go b/services/webhook/slack_test.go index d9828f374f0..7ebf16aba29 100644 --- a/services/webhook/slack_test.go +++ b/services/webhook/slack_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,189 +17,180 @@ import ( ) func TestSlackPayload(t *testing.T) { + sc := slackConvertor{} + t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(SlackPayload) - pl, err := d.Create(p) + pl, err := sc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[:] branch created by user1", pl.(*SlackPayload).Text) + assert.Equal(t, "[:] branch created by user1", pl.Text) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(SlackPayload) - pl, err := d.Delete(p) + pl, err := sc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[:test] branch deleted by user1", pl.(*SlackPayload).Text) + assert.Equal(t, "[:test] branch deleted by user1", pl.Text) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(SlackPayload) - pl, err := d.Fork(p) + pl, err := sc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, " is forked to ", pl.(*SlackPayload).Text) + assert.Equal(t, " is forked to ", pl.Text) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(SlackPayload) - pl, err := d.Push(p) + pl, err := sc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[:] 2 new commits pushed by user1", pl.(*SlackPayload).Text) + assert.Equal(t, "[:] 2 new commits pushed by user1", pl.Text) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(SlackPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := sc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Issue opened: by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Issue opened: by ", pl.Text) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = sc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Issue closed: by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Issue closed: by ", pl.Text) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(SlackPayload) - pl, err := d.IssueComment(p) + pl, err := sc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] New comment on issue by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] New comment on issue by ", pl.Text) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(SlackPayload) - pl, err := d.PullRequest(p) + pl, err := sc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Pull request opened: by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Pull request opened: by ", pl.Text) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(SlackPayload) - pl, err := d.IssueComment(p) + pl, err := sc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] New comment on pull request by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] New comment on pull request by ", pl.Text) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(SlackPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := sc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Pull request review approved: [#12 Fix bug](http://localhost:3000/test/repo/pulls/12) by ", pl.Text) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(SlackPayload) - pl, err := d.Repository(p) + pl, err := sc.Repository(p) + require.NoError(t, err) + + assert.Equal(t, "[] Repository created by ", pl.Text) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := sc.Package(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Repository created by ", pl.(*SlackPayload).Text) + assert.Equal(t, "Package created: by ", pl.Text) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(SlackPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := sc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] New wiki page '' (Wiki change comment) by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] New wiki page '' (Wiki change comment) by ", pl.Text) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = sc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Wiki page '' edited (Wiki change comment) by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Wiki page '' edited (Wiki change comment) by ", pl.Text) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = sc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Wiki page '' deleted by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Wiki page '' deleted by ", pl.Text) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(SlackPayload) - pl, err := d.Release(p) + pl, err := sc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Release created: by ", pl.(*SlackPayload).Text) + assert.Equal(t, "[] Release created: by ", pl.Text) }) } func TestSlackJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(SlackPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.SLACK, + URL: "https://slack.example.com/", + Meta: `{}`, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newSlackRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://slack.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body SlackPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[:] 2 new commits pushed by user1", body.Text) } func TestIsValidSlackChannel(t *testing.T) { diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index ea7e8185def..c2b48200324 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -4,14 +4,15 @@ package webhook import ( + "context" "fmt" + "net/http" "strings" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/markup" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -41,22 +42,8 @@ func GetTelegramHook(w *webhook_model.Webhook) *TelegramMeta { return s } -var _ PayloadConvertor = &TelegramPayload{} - -// JSONPayload Marshals the TelegramPayload to json -func (t *TelegramPayload) JSONPayload() ([]byte, error) { - t.ParseMode = "HTML" - t.DisableWebPreview = true - t.Message = markup.Sanitize(t.Message) - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - // Create implements PayloadConvertor Create method -func (t *TelegramPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (t telegramConvertor) Create(p *api.CreatePayload) (TelegramPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf(`[%s] %s %s created`, p.Repo.HTMLURL, p.Repo.FullName, p.RefType, @@ -66,7 +53,7 @@ func (t *TelegramPayload) Create(p *api.CreatePayload) (api.Payloader, error) { } // Delete implements PayloadConvertor Delete method -func (t *TelegramPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (t telegramConvertor) Delete(p *api.DeletePayload) (TelegramPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf(`[%s] %s %s deleted`, p.Repo.HTMLURL, p.Repo.FullName, p.RefType, @@ -76,14 +63,14 @@ func (t *TelegramPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { } // Fork implements PayloadConvertor Fork method -func (t *TelegramPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (t telegramConvertor) Fork(p *api.ForkPayload) (TelegramPayload, error) { title := fmt.Sprintf(`%s is forked to %s`, p.Forkee.FullName, p.Repo.HTMLURL, p.Repo.FullName) return createTelegramPayload(title), nil } // Push implements PayloadConvertor Push method -func (t *TelegramPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (t telegramConvertor) Push(p *api.PushPayload) (TelegramPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -121,34 +108,34 @@ func (t *TelegramPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (t *TelegramPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (t telegramConvertor) Issue(p *api.IssuePayload) (TelegramPayload, error) { text, _, attachmentText, _ := getIssuesPayloadInfo(p, htmlLinkFormatter, true) return createTelegramPayload(text + "\n\n" + attachmentText), nil } // IssueComment implements PayloadConvertor IssueComment method -func (t *TelegramPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (t telegramConvertor) IssueComment(p *api.IssueCommentPayload) (TelegramPayload, error) { text, _, _ := getIssueCommentPayloadInfo(p, htmlLinkFormatter, true) return createTelegramPayload(text + "\n" + p.Comment.Body), nil } // PullRequest implements PayloadConvertor PullRequest method -func (t *TelegramPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (t telegramConvertor) PullRequest(p *api.PullRequestPayload) (TelegramPayload, error) { text, _, attachmentText, _ := getPullRequestPayloadInfo(p, htmlLinkFormatter, true) return createTelegramPayload(text + "\n" + attachmentText), nil } // Review implements PayloadConvertor Review method -func (t *TelegramPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (t telegramConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (TelegramPayload, error) { var text, attachmentText string switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return TelegramPayload{}, err } text = fmt.Sprintf("[%s] Pull request review %s: #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) @@ -159,7 +146,7 @@ func (t *TelegramPayload) Review(p *api.PullRequestPayload, event webhook_module } // Repository implements PayloadConvertor Repository method -func (t *TelegramPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (t telegramConvertor) Repository(p *api.RepositoryPayload) (TelegramPayload, error) { var title string switch p.Action { case api.HookRepoCreated: @@ -169,30 +156,41 @@ func (t *TelegramPayload) Repository(p *api.RepositoryPayload) (api.Payloader, e title = fmt.Sprintf("[%s] Repository deleted", p.Repository.FullName) return createTelegramPayload(title), nil } - return nil, nil + return TelegramPayload{}, nil } // Wiki implements PayloadConvertor Wiki method -func (t *TelegramPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (t telegramConvertor) Wiki(p *api.WikiPayload) (TelegramPayload, error) { text, _, _ := getWikiPayloadInfo(p, htmlLinkFormatter, true) return createTelegramPayload(text), nil } // Release implements PayloadConvertor Release method -func (t *TelegramPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (t telegramConvertor) Release(p *api.ReleasePayload) (TelegramPayload, error) { text, _ := getReleasePayloadInfo(p, htmlLinkFormatter, true) return createTelegramPayload(text), nil } -// GetTelegramPayload converts a telegram webhook into a TelegramPayload -func GetTelegramPayload(p api.Payloader, event webhook_module.HookEventType, _ string) (api.Payloader, error) { - return convertPayloader(new(TelegramPayload), p, event) +func (t telegramConvertor) Package(p *api.PackagePayload) (TelegramPayload, error) { + text, _ := getPackagePayloadInfo(p, htmlLinkFormatter, true) + + return createTelegramPayload(text), nil } -func createTelegramPayload(message string) *TelegramPayload { - return &TelegramPayload{ - Message: strings.TrimSpace(message), +func createTelegramPayload(message string) TelegramPayload { + return TelegramPayload{ + Message: strings.TrimSpace(message), + ParseMode: "HTML", + DisableWebPreview: true, } } + +type telegramConvertor struct{} + +var _ payloadConvertor[TelegramPayload] = telegramConvertor{} + +func newTelegramRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + return newJSONRequest(telegramConvertor{}, w, t, true) +} diff --git a/services/webhook/telegram_test.go b/services/webhook/telegram_test.go index b42b0ccda88..2fe5161b228 100644 --- a/services/webhook/telegram_test.go +++ b/services/webhook/telegram_test.go @@ -4,8 +4,11 @@ package webhook import ( + "context" "testing" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -14,187 +17,186 @@ import ( ) func TestTelegramPayload(t *testing.T) { + tc := telegramConvertor{} + + t.Run("Correct webhook params", func(t *testing.T) { + p := createTelegramPayload("testMsg ") + + assert.Equal(t, "HTML", p.ParseMode) + assert.Equal(t, true, p.DisableWebPreview) + assert.Equal(t, "testMsg", p.Message) + }) + t.Run("Create", func(t *testing.T) { p := createTestPayload() - d := new(TelegramPayload) - pl, err := d.Create(p) + pl, err := tc.Create(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] branch test created`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] branch test created`, pl.Message) }) t.Run("Delete", func(t *testing.T) { p := deleteTestPayload() - d := new(TelegramPayload) - pl, err := d.Delete(p) + pl, err := tc.Delete(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] branch test deleted`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] branch test deleted`, pl.Message) }) t.Run("Fork", func(t *testing.T) { p := forkTestPayload() - d := new(TelegramPayload) - pl, err := d.Fork(p) + pl, err := tc.Fork(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `test/repo2 is forked to test/repo`, pl.(*TelegramPayload).Message) + assert.Equal(t, `test/repo2 is forked to test/repo`, pl.Message) }) t.Run("Push", func(t *testing.T) { p := pushTestPayload() - d := new(TelegramPayload) - pl, err := d.Push(p) + pl, err := tc.Push(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo:test] 2 new commits\n[2020558] commit message - user1\n[2020558] commit message - user1", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo:test] 2 new commits\n[2020558] commit message - user1\n[2020558] commit message - user1", pl.Message) }) t.Run("Issue", func(t *testing.T) { p := issueTestPayload() - d := new(TelegramPayload) p.Action = api.HookIssueOpened - pl, err := d.Issue(p) + pl, err := tc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\n\nissue body", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo] Issue opened: #2 crash by user1\n\nissue body", pl.Message) p.Action = api.HookIssueClosed - pl, err = d.Issue(p) + pl, err = tc.Issue(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] Issue closed: #2 crash by user1`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] Issue closed: #2 crash by user1`, pl.Message) }) t.Run("IssueComment", func(t *testing.T) { p := issueCommentTestPayload() - d := new(TelegramPayload) - pl, err := d.IssueComment(p) + pl, err := tc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo] New comment on issue #2 crash by user1\nmore info needed", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo] New comment on issue #2 crash by user1\nmore info needed", pl.Message) }) t.Run("PullRequest", func(t *testing.T) { p := pullRequestTestPayload() - d := new(TelegramPayload) - pl, err := d.PullRequest(p) + pl, err := tc.PullRequest(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug by user1\nfixes bug #2", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo] Pull request opened: #12 Fix bug by user1\nfixes bug #2", pl.Message) }) t.Run("PullRequestComment", func(t *testing.T) { p := pullRequestCommentTestPayload() - d := new(TelegramPayload) - pl, err := d.IssueComment(p) + pl, err := tc.IssueComment(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug by user1\nchanges requested", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo] New comment on pull request #12 Fix bug by user1\nchanges requested", pl.Message) }) t.Run("Review", func(t *testing.T) { p := pullRequestTestPayload() p.Action = api.HookIssueReviewed - d := new(TelegramPayload) - pl, err := d.Review(p, webhook_module.HookEventPullRequestReviewApproved) + pl, err := tc.Review(p, webhook_module.HookEventPullRequestReviewApproved) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug\ngood job", pl.(*TelegramPayload).Message) + assert.Equal(t, "[test/repo] Pull request review approved: #12 Fix bug\ngood job", pl.Message) }) t.Run("Repository", func(t *testing.T) { p := repositoryTestPayload() - d := new(TelegramPayload) - pl, err := d.Repository(p) + pl, err := tc.Repository(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] Repository created`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] Repository created`, pl.Message) + }) + + t.Run("Package", func(t *testing.T) { + p := packageTestPayload() + + pl, err := tc.Package(p) + require.NoError(t, err) + + assert.Equal(t, `Package created: GiteaContainer:latest by user1`, pl.Message) }) t.Run("Wiki", func(t *testing.T) { p := wikiTestPayload() - d := new(TelegramPayload) p.Action = api.HookWikiCreated - pl, err := d.Wiki(p) + pl, err := tc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] New wiki page 'index' (Wiki change comment) by user1`, pl.Message) p.Action = api.HookWikiEdited - pl, err = d.Wiki(p) + pl, err = tc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] Wiki page 'index' edited (Wiki change comment) by user1`, pl.Message) p.Action = api.HookWikiDeleted - pl, err = d.Wiki(p) + pl, err = tc.Wiki(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] Wiki page 'index' deleted by user1`, pl.Message) }) t.Run("Release", func(t *testing.T) { p := pullReleaseTestPayload() - d := new(TelegramPayload) - pl, err := d.Release(p) + pl, err := tc.Release(p) require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - assert.Equal(t, `[test/repo] Release created: v1.0 by user1`, pl.(*TelegramPayload).Message) + assert.Equal(t, `[test/repo] Release created: v1.0 by user1`, pl.Message) }) } func TestTelegramJSONPayload(t *testing.T) { p := pushTestPayload() - - pl, err := new(TelegramPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &TelegramPayload{}, pl) - json, err := pl.JSONPayload() + hook := &webhook_model.Webhook{ + RepoID: 3, + IsActive: true, + Type: webhook_module.TELEGRAM, + URL: "https://telegram.example.com/", + Meta: ``, + HTTPMethod: "POST", + } + task := &webhook_model.HookTask{ + HookID: hook.ID, + EventType: webhook_module.HookEventPush, + PayloadContent: string(data), + PayloadVersion: 2, + } + + req, reqBody, err := newTelegramRequest(context.Background(), hook, task) + require.NotNil(t, req) + require.NotNil(t, reqBody) require.NoError(t, err) - assert.NotEmpty(t, json) + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "https://telegram.example.com/", req.URL.String()) + assert.Equal(t, "sha256=", req.Header.Get("X-Hub-Signature-256")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + var body TelegramPayload + err = json.NewDecoder(req.Body).Decode(&body) + assert.NoError(t, err) + assert.Equal(t, "[test/repo:test] 2 new commits\n[2020558] commit message - user1\n[2020558] commit message - user1", body.Message) } diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index 9d5dab85f79..e0e8fa2fc19 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -7,14 +7,17 @@ import ( "context" "errors" "fmt" + "net/http" "strings" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -24,48 +27,16 @@ import ( "github.com/gobwas/glob" ) -type webhook struct { - name webhook_module.HookType - payloadCreator func(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) -} - -var webhooks = map[webhook_module.HookType]*webhook{ - webhook_module.SLACK: { - name: webhook_module.SLACK, - payloadCreator: GetSlackPayload, - }, - webhook_module.DISCORD: { - name: webhook_module.DISCORD, - payloadCreator: GetDiscordPayload, - }, - webhook_module.DINGTALK: { - name: webhook_module.DINGTALK, - payloadCreator: GetDingtalkPayload, - }, - webhook_module.TELEGRAM: { - name: webhook_module.TELEGRAM, - payloadCreator: GetTelegramPayload, - }, - webhook_module.MSTEAMS: { - name: webhook_module.MSTEAMS, - payloadCreator: GetMSTeamsPayload, - }, - webhook_module.FEISHU: { - name: webhook_module.FEISHU, - payloadCreator: GetFeishuPayload, - }, - webhook_module.MATRIX: { - name: webhook_module.MATRIX, - payloadCreator: GetMatrixPayload, - }, - webhook_module.WECHATWORK: { - name: webhook_module.WECHATWORK, - payloadCreator: GetWechatworkPayload, - }, - webhook_module.PACKAGIST: { - name: webhook_module.PACKAGIST, - payloadCreator: GetPackagistPayload, - }, +var webhookRequesters = map[webhook_module.HookType]func(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error){ + webhook_module.SLACK: newSlackRequest, + webhook_module.DISCORD: newDiscordRequest, + webhook_module.DINGTALK: newDingtalkRequest, + webhook_module.TELEGRAM: newTelegramRequest, + webhook_module.MSTEAMS: newMSTeamsRequest, + webhook_module.FEISHU: newFeishuRequest, + webhook_module.MATRIX: newMatrixRequest, + webhook_module.WECHATWORK: newWechatworkRequest, + webhook_module.PACKAGIST: newPackagistRequest, } // IsValidHookTaskType returns true if a webhook registered @@ -73,7 +44,7 @@ func IsValidHookTaskType(name string) bool { if name == webhook_module.GITEA || name == webhook_module.GOGS { return true } - _, ok := webhooks[name] + _, ok := webhookRequesters[name] return ok } @@ -157,7 +128,9 @@ func checkBranch(w *webhook_model.Webhook, branch string) bool { return g.Match(branch) } -// PrepareWebhook creates a hook task and enqueues it for processing +// PrepareWebhook creates a hook task and enqueues it for processing. +// The payload is saved as-is. The adjustments depending on the webhook type happen +// right before delivery, in the [Deliver] method. func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error { // Skip sending if webhooks are disabled. if setting.DisableWebhooks { @@ -191,25 +164,19 @@ func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook } } - var payloader api.Payloader - var err error - webhook, ok := webhooks[w.Type] - if ok { - payloader, err = webhook.payloadCreator(p, event, w.Meta) - if err != nil { - return fmt.Errorf("create payload for %s[%s]: %w", w.Type, event, err) - } - } else { - payloader = p + payload, err := p.JSONPayload() + if err != nil { + return fmt.Errorf("JSONPayload for %s: %w", event, err) } task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{ - HookID: w.ID, - Payloader: payloader, - EventType: event, + HookID: w.ID, + PayloadContent: string(payload), + EventType: event, + PayloadVersion: 2, }) if err != nil { - return fmt.Errorf("CreateHookTask: %w", err) + return fmt.Errorf("CreateHookTask for %s: %w", event, err) } return enqueueHookTask(task.ID) @@ -222,9 +189,9 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu var ws []*webhook_model.Webhook if source.Repository != nil { - repoHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{ + repoHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{ RepoID: source.Repository.ID, - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), }) if err != nil { return fmt.Errorf("ListWebhooksByOpts: %w", err) @@ -236,9 +203,9 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu // append additional webhooks of a user or organization if owner != nil { - ownerHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{ + ownerHooks, err := db.Find[webhook_model.Webhook](ctx, webhook_model.ListWebhookOptions{ OwnerID: owner.ID, - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), }) if err != nil { return fmt.Errorf("ListWebhooksByOpts: %w", err) @@ -247,7 +214,7 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu } // Add any admin-defined system webhooks - systemHooks, err := webhook_model.GetSystemWebhooks(ctx, util.OptionalBoolTrue) + systemHooks, err := webhook_model.GetSystemWebhooks(ctx, optional.Some(true)) if err != nil { return fmt.Errorf("GetSystemWebhooks: %w", err) } diff --git a/services/webhook/webhook_test.go b/services/webhook/webhook_test.go index 338b94360bb..5f5c1462323 100644 --- a/services/webhook/webhook_test.go +++ b/services/webhook/webhook_test.go @@ -77,7 +77,3 @@ func TestPrepareWebhooksBranchFilterNoMatch(t *testing.T) { unittest.AssertNotExistsBean(t, hookTask) } } - -// TODO TestHookTask_deliver - -// TODO TestDeliverHooks diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index a7680f1c671..46e7856ecfa 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -4,11 +4,13 @@ package webhook import ( + "context" "fmt" + "net/http" "strings" + webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" ) @@ -28,20 +30,8 @@ type ( } ) -// SetSecret sets the Wechatwork secret -func (f *WechatworkPayload) SetSecret(_ string) {} - -// JSONPayload Marshals the WechatworkPayload to json -func (f *WechatworkPayload) JSONPayload() ([]byte, error) { - data, err := json.MarshalIndent(f, "", " ") - if err != nil { - return []byte{}, err - } - return data, nil -} - -func newWechatworkMarkdownPayload(title string) *WechatworkPayload { - return &WechatworkPayload{ +func newWechatworkMarkdownPayload(title string) WechatworkPayload { + return WechatworkPayload{ Msgtype: "markdown", Markdown: struct { Content string `json:"content"` @@ -51,10 +41,8 @@ func newWechatworkMarkdownPayload(title string) *WechatworkPayload { } } -var _ PayloadConvertor = &WechatworkPayload{} - // Create implements PayloadConvertor Create method -func (f *WechatworkPayload) Create(p *api.CreatePayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Create(p *api.CreatePayload) (WechatworkPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s created", p.Repo.FullName, p.RefType, refName) @@ -63,7 +51,7 @@ func (f *WechatworkPayload) Create(p *api.CreatePayload) (api.Payloader, error) } // Delete implements PayloadConvertor Delete method -func (f *WechatworkPayload) Delete(p *api.DeletePayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Delete(p *api.DeletePayload) (WechatworkPayload, error) { // created tag/branch refName := git.RefName(p.Ref).ShortName() title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) @@ -72,14 +60,14 @@ func (f *WechatworkPayload) Delete(p *api.DeletePayload) (api.Payloader, error) } // Fork implements PayloadConvertor Fork method -func (f *WechatworkPayload) Fork(p *api.ForkPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Fork(p *api.ForkPayload) (WechatworkPayload, error) { title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) return newWechatworkMarkdownPayload(title), nil } // Push implements PayloadConvertor Push method -func (f *WechatworkPayload) Push(p *api.PushPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Push(p *api.PushPayload) (WechatworkPayload, error) { var ( branchName = git.RefName(p.Ref).ShortName() commitDesc string @@ -108,7 +96,7 @@ func (f *WechatworkPayload) Push(p *api.PushPayload) (api.Payloader, error) { } // Issue implements PayloadConvertor Issue method -func (f *WechatworkPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Issue(p *api.IssuePayload) (WechatworkPayload, error) { text, issueTitle, attachmentText, _ := getIssuesPayloadInfo(p, noneLinkFormatter, true) var content string content += fmt.Sprintf(" >%s\n >%s \n > %s \n [%s](%s)", text, attachmentText, issueTitle, p.Issue.HTMLURL, p.Issue.HTMLURL) @@ -117,7 +105,7 @@ func (f *WechatworkPayload) Issue(p *api.IssuePayload) (api.Payloader, error) { } // IssueComment implements PayloadConvertor IssueComment method -func (f *WechatworkPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) IssueComment(p *api.IssueCommentPayload) (WechatworkPayload, error) { text, issueTitle, _ := getIssueCommentPayloadInfo(p, noneLinkFormatter, true) var content string content += fmt.Sprintf(" >%s\n >%s \n >%s \n [%s](%s)", text, p.Comment.Body, issueTitle, p.Comment.HTMLURL, p.Comment.HTMLURL) @@ -126,7 +114,7 @@ func (f *WechatworkPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloa } // PullRequest implements PayloadConvertor PullRequest method -func (f *WechatworkPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) PullRequest(p *api.PullRequestPayload) (WechatworkPayload, error) { text, issueTitle, attachmentText, _ := getPullRequestPayloadInfo(p, noneLinkFormatter, true) pr := fmt.Sprintf("> %s \r\n > %s \r\n > %s \r\n", text, issueTitle, attachmentText) @@ -135,13 +123,13 @@ func (f *WechatworkPayload) PullRequest(p *api.PullRequestPayload) (api.Payloade } // Review implements PayloadConvertor Review method -func (f *WechatworkPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) { +func (wc wechatworkConvertor) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (WechatworkPayload, error) { var text, title string switch p.Action { case api.HookIssueReviewed: action, err := parseHookPullRequestEventType(event) if err != nil { - return nil, err + return WechatworkPayload{}, err } title = fmt.Sprintf("[%s] Pull request review %s : #%d %s", p.Repository.FullName, action, p.Index, p.PullRequest.Title) text = p.Review.Content @@ -151,7 +139,7 @@ func (f *WechatworkPayload) Review(p *api.PullRequestPayload, event webhook_modu } // Repository implements PayloadConvertor Repository method -func (f *WechatworkPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Repository(p *api.RepositoryPayload) (WechatworkPayload, error) { var title string switch p.Action { case api.HookRepoCreated: @@ -162,24 +150,33 @@ func (f *WechatworkPayload) Repository(p *api.RepositoryPayload) (api.Payloader, return newWechatworkMarkdownPayload(title), nil } - return nil, nil + return WechatworkPayload{}, nil } // Wiki implements PayloadConvertor Wiki method -func (f *WechatworkPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Wiki(p *api.WikiPayload) (WechatworkPayload, error) { text, _, _ := getWikiPayloadInfo(p, noneLinkFormatter, true) return newWechatworkMarkdownPayload(text), nil } // Release implements PayloadConvertor Release method -func (f *WechatworkPayload) Release(p *api.ReleasePayload) (api.Payloader, error) { +func (wc wechatworkConvertor) Release(p *api.ReleasePayload) (WechatworkPayload, error) { text, _ := getReleasePayloadInfo(p, noneLinkFormatter, true) return newWechatworkMarkdownPayload(text), nil } -// GetWechatworkPayload GetWechatworkPayload converts a ding talk webhook into a WechatworkPayload -func GetWechatworkPayload(p api.Payloader, event webhook_module.HookEventType, _ string) (api.Payloader, error) { - return convertPayloader(new(WechatworkPayload), p, event) +func (wc wechatworkConvertor) Package(p *api.PackagePayload) (WechatworkPayload, error) { + text, _ := getPackagePayloadInfo(p, noneLinkFormatter, true) + + return newWechatworkMarkdownPayload(text), nil +} + +type wechatworkConvertor struct{} + +var _ payloadConvertor[WechatworkPayload] = wechatworkConvertor{} + +func newWechatworkRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { + return newJSONRequest(wechatworkConvertor{}, w, t, true) } diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go index c0183dd2b59..fdcc5feefa7 100644 --- a/services/wiki/wiki.go +++ b/services/wiki/wiki.go @@ -6,28 +6,30 @@ package wiki import ( "context" + "errors" "fmt" "os" "strings" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/sync" + "code.gitea.io/gitea/modules/util" asymkey_service "code.gitea.io/gitea/services/asymkey" + repo_service "code.gitea.io/gitea/services/repository" ) // TODO: use clustered lock (unique queue? or *abuse* cache) var wikiWorkingPool = sync.NewExclusivePool() -const ( - DefaultRemote = "origin" - DefaultBranch = "master" -) +const DefaultRemote = "origin" // InitWiki initializes a wiki for repository, // it does nothing when repository already has wiki. @@ -36,29 +38,29 @@ func InitWiki(ctx context.Context, repo *repo_model.Repository) error { return nil } - if err := git.InitRepository(ctx, repo.WikiPath(), true); err != nil { + if err := git.InitRepository(ctx, repo.WikiPath(), true, repo.ObjectFormatName); err != nil { return fmt.Errorf("InitRepository: %w", err) } else if err = repo_module.CreateDelegateHooks(repo.WikiPath()); err != nil { return fmt.Errorf("createDelegateHooks: %w", err) - } else if _, _, err = git.NewCommand(ctx, "symbolic-ref", "HEAD", git.BranchPrefix+DefaultBranch).RunStdString(&git.RunOpts{Dir: repo.WikiPath()}); err != nil { - return fmt.Errorf("unable to set default wiki branch to master: %w", err) + } else if _, _, err = git.NewCommand(ctx, "symbolic-ref", "HEAD").AddDynamicArguments(git.BranchPrefix + repo.DefaultWikiBranch).RunStdString(&git.RunOpts{Dir: repo.WikiPath()}); err != nil { + return fmt.Errorf("unable to set default wiki branch to %q: %w", repo.DefaultWikiBranch, err) } return nil } // prepareGitPath try to find a suitable file path with file name by the given raw wiki name. // return: existence, prepared file path with name, error -func prepareGitPath(gitRepo *git.Repository, wikiPath WebPath) (bool, string, error) { +func prepareGitPath(gitRepo *git.Repository, defaultWikiBranch string, wikiPath WebPath) (bool, string, error) { unescaped := string(wikiPath) + ".md" gitPath := WebPathToGitPath(wikiPath) // Look for both files - filesInIndex, err := gitRepo.LsTree(DefaultBranch, unescaped, gitPath) + filesInIndex, err := gitRepo.LsTree(defaultWikiBranch, unescaped, gitPath) if err != nil { - if strings.Contains(err.Error(), "Not a valid object name master") { - return false, gitPath, nil + if strings.Contains(err.Error(), "Not a valid object name") { + return false, gitPath, nil // branch doesn't exist } - log.Error("%v", err) + log.Error("Wiki LsTree failed, err: %v", err) return false, gitPath, err } @@ -79,6 +81,11 @@ func prepareGitPath(gitRepo *git.Repository, wikiPath WebPath) (bool, string, er // updateWikiPage adds a new page or edits an existing page in repository wiki. func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldWikiName, newWikiName WebPath, content, message string, isNew bool) (err error) { + err = repo.MustNotBeArchived() + if err != nil { + return err + } + if err = validateWebPath(newWikiName); err != nil { return err } @@ -89,7 +96,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model return fmt.Errorf("InitWiki: %w", err) } - hasMasterBranch := git.IsBranchExist(ctx, repo.WikiPath(), DefaultBranch) + hasDefaultBranch := git.IsBranchExist(ctx, repo.WikiPath(), repo.DefaultWikiBranch) basePath, err := repo_module.CreateTemporaryPath("update-wiki") if err != nil { @@ -106,8 +113,8 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model Shared: true, } - if hasMasterBranch { - cloneOpts.Branch = DefaultBranch + if hasDefaultBranch { + cloneOpts.Branch = repo.DefaultWikiBranch } if err := git.Clone(ctx, repo.WikiPath(), basePath, cloneOpts); err != nil { @@ -122,14 +129,14 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model } defer gitRepo.Close() - if hasMasterBranch { + if hasDefaultBranch { if err := gitRepo.ReadTreeToIndex("HEAD"); err != nil { log.Error("Unable to read HEAD tree to index in: %s %v", basePath, err) return fmt.Errorf("fnable to read HEAD tree to index in: %s %w", basePath, err) } } - isWikiExist, newWikiPath, err := prepareGitPath(gitRepo, newWikiName) + isWikiExist, newWikiPath, err := prepareGitPath(gitRepo, repo.DefaultWikiBranch, newWikiName) if err != nil { return err } @@ -145,7 +152,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model isOldWikiExist := true oldWikiPath := newWikiPath if oldWikiName != newWikiName { - isOldWikiExist, oldWikiPath, err = prepareGitPath(gitRepo, oldWikiName) + isOldWikiExist, oldWikiPath, err = prepareGitPath(gitRepo, repo.DefaultWikiBranch, oldWikiName) if err != nil { return err } @@ -154,7 +161,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model if isOldWikiExist { err := gitRepo.RemoveFilesFromIndex(oldWikiPath) if err != nil { - log.Error("%v", err) + log.Error("RemoveFilesFromIndex failed: %v", err) return err } } @@ -164,18 +171,18 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model objectHash, err := gitRepo.HashObject(strings.NewReader(content)) if err != nil { - log.Error("%v", err) + log.Error("HashObject failed: %v", err) return err } if err := gitRepo.AddObjectToIndex("100644", objectHash, newWikiPath); err != nil { - log.Error("%v", err) + log.Error("AddObjectToIndex failed: %v", err) return err } tree, err := gitRepo.WriteTree() if err != nil { - log.Error("%v", err) + log.Error("WriteTree failed: %v", err) return err } @@ -185,7 +192,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model committer := doer.NewGitSig() - sign, signingKey, signer, _ := asymkey_service.SignWikiCommit(ctx, repo.WikiPath(), doer) + sign, signingKey, signer, _ := asymkey_service.SignWikiCommit(ctx, repo, doer) if sign { commitTreeOpts.KeyID = signingKey if repo.GetTrustModel() == repo_model.CommitterTrustModel || repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel { @@ -194,19 +201,19 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model } else { commitTreeOpts.NoGPGSign = true } - if hasMasterBranch { + if hasDefaultBranch { commitTreeOpts.Parents = []string{"HEAD"} } commitHash, err := gitRepo.CommitTree(doer.NewGitSig(), committer, tree, commitTreeOpts) if err != nil { - log.Error("%v", err) + log.Error("CommitTree failed: %v", err) return err } if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{ Remote: DefaultRemote, - Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, DefaultBranch), + Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch), Env: repo_module.FullPushingEnvironment( doer, doer, @@ -215,11 +222,11 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model 0, ), }); err != nil { - log.Error("%v", err) + log.Error("Push failed: %v", err) if git.IsErrPushOutOfDate(err) || git.IsErrPushRejected(err) { return err } - return fmt.Errorf("Push: %w", err) + return fmt.Errorf("failed to push: %w", err) } return nil @@ -238,6 +245,11 @@ func EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.R // DeleteWikiPage deletes a wiki page identified by its path. func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, wikiName WebPath) (err error) { + err = repo.MustNotBeArchived() + if err != nil { + return err + } + wikiWorkingPool.CheckIn(fmt.Sprint(repo.ID)) defer wikiWorkingPool.CheckOut(fmt.Sprint(repo.ID)) @@ -258,7 +270,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model if err := git.Clone(ctx, repo.WikiPath(), basePath, git.CloneRepoOptions{ Bare: true, Shared: true, - Branch: DefaultBranch, + Branch: repo.DefaultWikiBranch, }); err != nil { log.Error("Failed to clone repository: %s (%v)", repo.FullName(), err) return fmt.Errorf("failed to clone repository: %s (%w)", repo.FullName(), err) @@ -276,7 +288,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model return fmt.Errorf("unable to read HEAD tree to index in: %s %w", basePath, err) } - found, wikiPath, err := prepareGitPath(gitRepo, wikiName) + found, wikiPath, err := prepareGitPath(gitRepo, repo.DefaultWikiBranch, wikiName) if err != nil { return err } @@ -303,7 +315,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model committer := doer.NewGitSig() - sign, signingKey, signer, _ := asymkey_service.SignWikiCommit(ctx, repo.WikiPath(), doer) + sign, signingKey, signer, _ := asymkey_service.SignWikiCommit(ctx, repo, doer) if sign { commitTreeOpts.KeyID = signingKey if repo.GetTrustModel() == repo_model.CommitterTrustModel || repo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel { @@ -320,7 +332,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{ Remote: DefaultRemote, - Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, DefaultBranch), + Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch), Env: repo_module.FullPushingEnvironment( doer, doer, @@ -340,10 +352,48 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model // DeleteWiki removes the actual and local copy of repository wiki. func DeleteWiki(ctx context.Context, repo *repo_model.Repository) error { - if err := repo_model.UpdateRepositoryUnits(repo, nil, []unit.Type{unit.TypeWiki}); err != nil { + if err := repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit.Type{unit.TypeWiki}); err != nil { return err } system_model.RemoveAllWithNotice(ctx, "Delete repository wiki", repo.WikiPath()) return nil } + +func ChangeDefaultWikiBranch(ctx context.Context, repo *repo_model.Repository, newBranch string) error { + if !git.IsValidRefPattern(newBranch) { + return fmt.Errorf("invalid branch name: %s", newBranch) + } + return db.WithTx(ctx, func(ctx context.Context) error { + repo.DefaultWikiBranch = newBranch + if err := repo_model.UpdateRepositoryCols(ctx, repo, "default_wiki_branch"); err != nil { + return fmt.Errorf("unable to update database: %w", err) + } + + if !repo.HasWiki() { + return nil + } + + oldDefBranch, err := gitrepo.GetWikiDefaultBranch(ctx, repo) + if err != nil { + return fmt.Errorf("unable to get default branch: %w", err) + } + if oldDefBranch == newBranch { + return nil + } + + gitRepo, err := gitrepo.OpenWikiRepository(ctx, repo) + if errors.Is(err, util.ErrNotExist) { + return nil // no git repo on storage, no need to do anything else + } else if err != nil { + return fmt.Errorf("unable to open repository: %w", err) + } + defer gitRepo.Close() + + err = gitRepo.RenameBranch(oldDefBranch, newBranch) + if err != nil { + return fmt.Errorf("unable to rename default branch: %w", err) + } + return nil + }) +} diff --git a/services/wiki/wiki_path.go b/services/wiki/wiki_path.go index e51d6c630ca..74c70640436 100644 --- a/services/wiki/wiki_path.go +++ b/services/wiki/wiki_path.go @@ -9,7 +9,10 @@ import ( "strings" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/convert" ) // To define the wiki related concepts: @@ -155,3 +158,15 @@ func UserTitleToWebPath(base, title string) WebPath { } return WebPath(title) } + +// ToWikiPageMetaData converts meta information to a WikiPageMetaData +func ToWikiPageMetaData(wikiName WebPath, lastCommit *git.Commit, repo *repo_model.Repository) *api.WikiPageMetaData { + subURL := string(wikiName) + _, title := WebPathToUserTitle(wikiName) + return &api.WikiPageMetaData{ + Title: title, + HTMLURL: util.URLJoin(repo.HTMLURL(), "wiki", subURL), + SubURL: subURL, + LastCommit: convert.ToWikiCommit(lastCommit), + } +} diff --git a/services/wiki/wiki_test.go b/services/wiki/wiki_test.go index 0621456f3e8..0a18cffa253 100644 --- a/services/wiki/wiki_test.go +++ b/services/wiki/wiki_test.go @@ -5,7 +5,6 @@ package wiki import ( "math/rand" - "path/filepath" "strings" "testing" @@ -13,6 +12,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" _ "code.gitea.io/gitea/models/actions" @@ -20,9 +20,7 @@ import ( ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestWebPathSegments(t *testing.T) { @@ -167,10 +165,12 @@ func TestRepository_AddWikiPage(t *testing.T) { webPath := UserTitleToWebPath("", userTitle) assert.NoError(t, AddWikiPage(git.DefaultContext, doer, repo, webPath, wikiContent, commitMsg)) // Now need to show that the page has been added: - gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) - assert.NoError(t, err) + gitRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + if !assert.NoError(t, err) { + return + } defer gitRepo.Close() - masterTree, err := gitRepo.GetTree(DefaultBranch) + masterTree, err := gitRepo.GetTree(repo.DefaultWikiBranch) assert.NoError(t, err) gitPath := WebPathToGitPath(webPath) entry, err := masterTree.GetTreeEntryByPath(gitPath) @@ -213,9 +213,9 @@ func TestRepository_EditWikiPage(t *testing.T) { assert.NoError(t, EditWikiPage(git.DefaultContext, doer, repo, "Home", webPath, newWikiContent, commitMsg)) // Now need to show that the page has been added: - gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) + gitRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) assert.NoError(t, err) - masterTree, err := gitRepo.GetTree(DefaultBranch) + masterTree, err := gitRepo.GetTree(repo.DefaultWikiBranch) assert.NoError(t, err) gitPath := WebPathToGitPath(webPath) entry, err := masterTree.GetTreeEntryByPath(gitPath) @@ -237,10 +237,12 @@ func TestRepository_DeleteWikiPage(t *testing.T) { assert.NoError(t, DeleteWikiPage(git.DefaultContext, doer, repo, "Home")) // Now need to show that the page has been added: - gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) - assert.NoError(t, err) + gitRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + if !assert.NoError(t, err) { + return + } defer gitRepo.Close() - masterTree, err := gitRepo.GetTree(DefaultBranch) + masterTree, err := gitRepo.GetTree(repo.DefaultWikiBranch) assert.NoError(t, err) gitPath := WebPathToGitPath("Home") _, err = masterTree.GetTreeEntryByPath(gitPath) @@ -250,9 +252,11 @@ func TestRepository_DeleteWikiPage(t *testing.T) { func TestPrepareWikiFileName(t *testing.T) { unittest.PrepareTestEnv(t) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) + gitRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + if !assert.NoError(t, err) { + return + } defer gitRepo.Close() - assert.NoError(t, err) tests := []struct { name string @@ -276,7 +280,7 @@ func TestPrepareWikiFileName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { webPath := UserTitleToWebPath("", tt.arg) - existence, newWikiPath, err := prepareGitPath(gitRepo, webPath) + existence, newWikiPath, err := prepareGitPath(gitRepo, repo.DefaultWikiBranch, webPath) if (err != nil) != tt.wantErr { assert.NoError(t, err) return @@ -299,14 +303,16 @@ func TestPrepareWikiFileName_FirstPage(t *testing.T) { // Now create a temporaryDirectory tmpDir := t.TempDir() - err := git.InitRepository(git.DefaultContext, tmpDir, true) + err := git.InitRepository(git.DefaultContext, tmpDir, true, git.Sha1ObjectFormat.Name()) assert.NoError(t, err) gitRepo, err := git.OpenRepository(git.DefaultContext, tmpDir) + if !assert.NoError(t, err) { + return + } defer gitRepo.Close() - assert.NoError(t, err) - existence, newWikiPath, err := prepareGitPath(gitRepo, "Home") + existence, newWikiPath, err := prepareGitPath(gitRepo, "master", "Home") assert.False(t, existence) assert.NoError(t, err) assert.EqualValues(t, "Home.md", newWikiPath) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7c10074bc5e..4c09a9d588d 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -44,7 +44,7 @@ parts: source: . stage-packages: [ git, sqlite3, openssh-client ] build-packages: [ git, libpam0g-dev, libsqlite3-dev, build-essential] - build-snaps: [ go/1.21/stable, node/18/stable ] + build-snaps: [ go/1.22/stable, node/20/stable ] build-environment: - LDFLAGS: "" override-pull: | diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 00000000000..523b18841ed --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,246 @@ +import {fileURLToPath} from 'node:url'; + +const cssVarFiles = [ + fileURLToPath(new URL('web_src/css/base.css', import.meta.url)), + fileURLToPath(new URL('web_src/css/themes/theme-gitea-light.css', import.meta.url)), + fileURLToPath(new URL('web_src/css/themes/theme-gitea-dark.css', import.meta.url)), +]; + +/** @type {import('stylelint').Config} */ +export default { + plugins: [ + 'stylelint-declaration-strict-value', + 'stylelint-declaration-block-no-ignored-properties', + 'stylelint-value-no-unknown-custom-properties', + '@stylistic/stylelint-plugin', + ], + ignoreFiles: [ + '**/*.go', + '/web_src/fomantic', + ], + overrides: [ + { + files: ['**/chroma/*', '**/codemirror/*', '**/standalone/*', '**/console.css', 'font_i18n.css'], + rules: { + 'scale-unlimited/declaration-strict-value': null, + }, + }, + { + files: ['**/chroma/*', '**/codemirror/*'], + rules: { + 'block-no-empty': null, + }, + }, + { + files: ['**/*.vue'], + customSyntax: 'postcss-html', + }, + ], + rules: { + '@stylistic/at-rule-name-case': null, + '@stylistic/at-rule-name-newline-after': null, + '@stylistic/at-rule-name-space-after': null, + '@stylistic/at-rule-semicolon-newline-after': null, + '@stylistic/at-rule-semicolon-space-before': null, + '@stylistic/block-closing-brace-empty-line-before': null, + '@stylistic/block-closing-brace-newline-after': null, + '@stylistic/block-closing-brace-newline-before': null, + '@stylistic/block-closing-brace-space-after': null, + '@stylistic/block-closing-brace-space-before': null, + '@stylistic/block-opening-brace-newline-after': null, + '@stylistic/block-opening-brace-newline-before': null, + '@stylistic/block-opening-brace-space-after': null, + '@stylistic/block-opening-brace-space-before': 'always', + '@stylistic/color-hex-case': 'lower', + '@stylistic/declaration-bang-space-after': 'never', + '@stylistic/declaration-bang-space-before': null, + '@stylistic/declaration-block-semicolon-newline-after': null, + '@stylistic/declaration-block-semicolon-newline-before': null, + '@stylistic/declaration-block-semicolon-space-after': null, + '@stylistic/declaration-block-semicolon-space-before': 'never', + '@stylistic/declaration-block-trailing-semicolon': null, + '@stylistic/declaration-colon-newline-after': null, + '@stylistic/declaration-colon-space-after': null, + '@stylistic/declaration-colon-space-before': 'never', + '@stylistic/function-comma-newline-after': null, + '@stylistic/function-comma-newline-before': null, + '@stylistic/function-comma-space-after': null, + '@stylistic/function-comma-space-before': null, + '@stylistic/function-max-empty-lines': 0, + '@stylistic/function-parentheses-newline-inside': 'never-multi-line', + '@stylistic/function-parentheses-space-inside': null, + '@stylistic/function-whitespace-after': null, + '@stylistic/indentation': 2, + '@stylistic/linebreaks': null, + '@stylistic/max-empty-lines': 1, + '@stylistic/max-line-length': null, + '@stylistic/media-feature-colon-space-after': null, + '@stylistic/media-feature-colon-space-before': 'never', + '@stylistic/media-feature-name-case': null, + '@stylistic/media-feature-parentheses-space-inside': null, + '@stylistic/media-feature-range-operator-space-after': 'always', + '@stylistic/media-feature-range-operator-space-before': 'always', + '@stylistic/media-query-list-comma-newline-after': null, + '@stylistic/media-query-list-comma-newline-before': null, + '@stylistic/media-query-list-comma-space-after': null, + '@stylistic/media-query-list-comma-space-before': null, + '@stylistic/named-grid-areas-alignment': null, + '@stylistic/no-empty-first-line': null, + '@stylistic/no-eol-whitespace': true, + '@stylistic/no-extra-semicolons': true, + '@stylistic/no-missing-end-of-source-newline': null, + '@stylistic/number-leading-zero': null, + '@stylistic/number-no-trailing-zeros': null, + '@stylistic/property-case': 'lower', + '@stylistic/selector-attribute-brackets-space-inside': null, + '@stylistic/selector-attribute-operator-space-after': null, + '@stylistic/selector-attribute-operator-space-before': null, + '@stylistic/selector-combinator-space-after': null, + '@stylistic/selector-combinator-space-before': null, + '@stylistic/selector-descendant-combinator-no-non-space': null, + '@stylistic/selector-list-comma-newline-after': null, + '@stylistic/selector-list-comma-newline-before': null, + '@stylistic/selector-list-comma-space-after': 'always-single-line', + '@stylistic/selector-list-comma-space-before': 'never-single-line', + '@stylistic/selector-max-empty-lines': 0, + '@stylistic/selector-pseudo-class-case': 'lower', + '@stylistic/selector-pseudo-class-parentheses-space-inside': 'never', + '@stylistic/selector-pseudo-element-case': 'lower', + '@stylistic/string-quotes': 'double', + '@stylistic/unicode-bom': null, + '@stylistic/unit-case': 'lower', + '@stylistic/value-list-comma-newline-after': null, + '@stylistic/value-list-comma-newline-before': null, + '@stylistic/value-list-comma-space-after': null, + '@stylistic/value-list-comma-space-before': null, + '@stylistic/value-list-max-empty-lines': 0, + 'alpha-value-notation': null, + 'annotation-no-unknown': true, + 'at-rule-allowed-list': null, + 'at-rule-disallowed-list': null, + 'at-rule-empty-line-before': null, + 'at-rule-no-unknown': [true, {ignoreAtRules: ['tailwind']}], + 'at-rule-no-vendor-prefix': true, + 'at-rule-property-required-list': null, + 'block-no-empty': true, + 'color-function-notation': null, + 'color-hex-alpha': null, + 'color-hex-length': null, + 'color-named': null, + 'color-no-hex': null, + 'color-no-invalid-hex': true, + 'comment-empty-line-before': null, + 'comment-no-empty': true, + 'comment-pattern': null, + 'comment-whitespace-inside': null, + 'comment-word-disallowed-list': null, + 'csstools/value-no-unknown-custom-properties': [true, {importFrom: cssVarFiles}], + 'custom-media-pattern': null, + 'custom-property-empty-line-before': null, + 'custom-property-no-missing-var-function': true, + 'custom-property-pattern': null, + 'declaration-block-no-duplicate-custom-properties': true, + 'declaration-block-no-duplicate-properties': [true, {ignore: ['consecutive-duplicates-with-different-values']}], + 'declaration-block-no-redundant-longhand-properties': null, + 'declaration-block-no-shorthand-property-overrides': null, + 'declaration-block-single-line-max-declarations': null, + 'declaration-empty-line-before': null, + 'declaration-no-important': null, + 'declaration-property-max-values': null, + 'declaration-property-unit-allowed-list': null, + 'declaration-property-unit-disallowed-list': {'line-height': ['em']}, + 'declaration-property-value-allowed-list': null, + 'declaration-property-value-disallowed-list': null, + 'declaration-property-value-no-unknown': true, + 'font-family-name-quotes': 'always-where-recommended', + 'font-family-no-duplicate-names': true, + 'font-family-no-missing-generic-family-keyword': true, + 'font-weight-notation': null, + 'function-allowed-list': null, + 'function-calc-no-unspaced-operator': true, + 'function-disallowed-list': null, + 'function-linear-gradient-no-nonstandard-direction': true, + 'function-name-case': 'lower', + 'function-no-unknown': true, + 'function-url-no-scheme-relative': null, + 'function-url-quotes': 'always', + 'function-url-scheme-allowed-list': null, + 'function-url-scheme-disallowed-list': null, + 'hue-degree-notation': null, + 'import-notation': 'string', + 'keyframe-block-no-duplicate-selectors': true, + 'keyframe-declaration-no-important': true, + 'keyframe-selector-notation': null, + 'keyframes-name-pattern': null, + 'length-zero-no-unit': [true, {ignore: ['custom-properties']}, {ignoreFunctions: ['var']}], + 'max-nesting-depth': null, + 'media-feature-name-allowed-list': null, + 'media-feature-name-disallowed-list': null, + 'media-feature-name-no-unknown': true, + 'media-feature-name-no-vendor-prefix': true, + 'media-feature-name-unit-allowed-list': null, + 'media-feature-name-value-allowed-list': null, + 'media-feature-name-value-no-unknown': true, + 'media-feature-range-notation': null, + 'media-query-no-invalid': true, + 'named-grid-areas-no-invalid': true, + 'no-descending-specificity': null, + 'no-duplicate-at-import-rules': true, + 'no-duplicate-selectors': true, + 'no-empty-source': true, + 'no-invalid-double-slash-comments': true, + 'no-invalid-position-at-import-rule': [true, {ignoreAtRules: ['tailwind']}], + 'no-irregular-whitespace': true, + 'no-unknown-animations': null, + 'no-unknown-custom-properties': null, + 'number-max-precision': null, + 'plugin/declaration-block-no-ignored-properties': true, + 'property-allowed-list': null, + 'property-disallowed-list': null, + 'property-no-unknown': true, + 'property-no-vendor-prefix': null, + 'rule-empty-line-before': null, + 'rule-selector-property-disallowed-list': null, + 'scale-unlimited/declaration-strict-value': [['/color$/', 'font-weight'], {ignoreValues: '/^(inherit|transparent|unset|initial|currentcolor|none)$/', ignoreFunctions: false, disableFix: true, expandShorthand: true}], + 'selector-anb-no-unmatchable': true, + 'selector-attribute-name-disallowed-list': null, + 'selector-attribute-operator-allowed-list': null, + 'selector-attribute-operator-disallowed-list': null, + 'selector-attribute-quotes': 'always', + 'selector-class-pattern': null, + 'selector-combinator-allowed-list': null, + 'selector-combinator-disallowed-list': null, + 'selector-disallowed-list': null, + 'selector-id-pattern': null, + 'selector-max-attribute': null, + 'selector-max-class': null, + 'selector-max-combinators': null, + 'selector-max-compound-selectors': null, + 'selector-max-id': null, + 'selector-max-pseudo-class': null, + 'selector-max-specificity': null, + 'selector-max-type': null, + 'selector-max-universal': null, + 'selector-nested-pattern': null, + 'selector-no-qualifying-type': null, + 'selector-no-vendor-prefix': true, + 'selector-not-notation': null, + 'selector-pseudo-class-allowed-list': null, + 'selector-pseudo-class-disallowed-list': null, + 'selector-pseudo-class-no-unknown': true, + 'selector-pseudo-element-allowed-list': null, + 'selector-pseudo-element-colon-notation': 'double', + 'selector-pseudo-element-disallowed-list': null, + 'selector-pseudo-element-no-unknown': true, + 'selector-type-case': 'lower', + 'selector-type-no-unknown': [true, {ignore: ['custom-elements']}], + 'shorthand-property-no-redundant-values': true, + 'string-no-newline': true, + 'time-min-milliseconds': null, + 'unit-allowed-list': null, + 'unit-disallowed-list': null, + 'unit-no-unknown': true, + 'value-keyword-case': null, + 'value-no-vendor-prefix': [true, {ignoreValues: ['box', 'inline-box']}], + }, +}; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000000..d49e9d7a1c7 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,101 @@ +import {readFileSync} from 'node:fs'; +import {env} from 'node:process'; +import {parse} from 'postcss'; + +const isProduction = env.NODE_ENV !== 'development'; + +function extractRootVars(css) { + const root = parse(css); + const vars = new Set(); + root.walkRules((rule) => { + if (rule.selector !== ':root') return; + rule.each((decl) => { + if (decl.value && decl.prop.startsWith('--')) { + vars.add(decl.prop.substring(2)); + } + }); + }); + return Array.from(vars); +} + +const vars = extractRootVars([ + readFileSync(new URL('web_src/css/themes/theme-gitea-light.css', import.meta.url), 'utf8'), + readFileSync(new URL('web_src/css/themes/theme-gitea-dark.css', import.meta.url), 'utf8'), +].join('\n')); + +export default { + prefix: 'tw-', + important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles + content: [ + isProduction && '!./templates/devtest/**/*', + isProduction && '!./web_src/js/standalone/devtest.js', + '!./templates/swagger/v1_json.tmpl', + '!./templates/user/auth/oidc_wellknown.tmpl', + '!**/*_test.go', + '!./modules/{public,options,templates}/bindata.go', + './{build,models,modules,routers,services}/**/*.go', + './templates/**/*.tmpl', + './web_src/js/**/*.{js,vue}', + ].filter(Boolean), + blocklist: [ + // classes that don't work without CSS variables from "@tailwind base" which we don't use + 'transform', 'shadow', 'ring', 'blur', 'grayscale', 'invert', '!invert', 'filter', '!filter', + 'backdrop-filter', + // we use double-class tw-hidden defined in web_src/css/helpers.css for increased specificity + 'hidden', + // unneeded classes + '[-a-zA-Z:0-9_.]', + ], + theme: { + colors: { + // make `tw-bg-red` etc work with our CSS variables + ...Object.fromEntries(vars.filter((prop) => prop.startsWith('color-')).map((prop) => { + const color = prop.substring(6); + return [color, `var(--color-${color})`]; + })), + inherit: 'inherit', + current: 'currentcolor', + transparent: 'transparent', + }, + borderRadius: { + 'none': '0', + 'sm': '2px', + 'DEFAULT': 'var(--border-radius)', // 4px + 'md': 'var(--border-radius-medium)', // 6px + 'lg': '8px', + 'xl': '12px', + '2xl': '16px', + '3xl': '24px', + 'full': 'var(--border-radius-circle)', // 50% + }, + fontFamily: { + sans: 'var(--fonts-regular)', + mono: 'var(--fonts-monospace)', + }, + fontWeight: { + light: 'var(--font-weight-light)', + normal: 'var(--font-weight-normal)', + medium: 'var(--font-weight-medium)', + semibold: 'var(--font-weight-semibold)', + bold: 'var(--font-weight-bold)', + }, + fontSize: { // not using `rem` units because our root is currently 14px + 'xs': '12px', + 'sm': '14px', + 'base': '16px', + 'lg': '18px', + 'xl': '20px', + '2xl': '24px', + '3xl': '30px', + '4xl': '36px', + '5xl': '48px', + '6xl': '60px', + '7xl': '72px', + '8xl': '96px', + '9xl': '128px', + ...Object.fromEntries(Array.from({length: 100}, (_, i) => { + return [`${i}`, `${i === 0 ? '0' : `${i}px`}`]; + })), + }, + }, +}; diff --git a/templates/admin/actions.tmpl b/templates/admin/actions.tmpl index 9640e0fd1f4..597863d73b1 100644 --- a/templates/admin/actions.tmpl +++ b/templates/admin/actions.tmpl @@ -3,5 +3,8 @@ {{if eq .PageType "runners"}} {{template "shared/actions/runner_list" .}} {{end}} + {{if eq .PageType "variables"}} + {{template "shared/variables/variable_list" .}} + {{end}}
{{template "admin/layout_footer" .}} diff --git a/templates/admin/applications/list.tmpl b/templates/admin/applications/list.tmpl index a292051fd0d..cbb66b16053 100644 --- a/templates/admin/applications/list.tmpl +++ b/templates/admin/applications/list.tmpl @@ -1,7 +1,7 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}}

- {{.locale.Tr "settings.applications"}} + {{ctx.Locale.Tr "settings.applications"}}

{{template "user/settings/applications_oauth2_list" .}}
diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 6dfa86d9dd1..e140d6b5ebf 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -1,7 +1,7 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit authentication")}}

- {{.locale.Tr "admin.auths.edit"}} + {{ctx.Locale.Tr "admin.auths.edit"}}

@@ -9,12 +9,12 @@ {{.CsrfTokenHtml}}
- + {{.Source.TypeName}}
- +
@@ -22,7 +22,7 @@ {{if or .Source.IsLDAP .Source.IsDLDAP}} {{$cfg:=.Source.Cfg}}
- +
- +
- +
-
+
- +
{{if .Source.IsLDAP}}
- +
- +
{{end}}
- +
{{if .Source.IsDLDAP}}
- +
{{end}}
- +
- +
- + -

{{.locale.Tr "admin.auths.restricted_filter_helper"}}

+

{{ctx.Locale.Tr "admin.auths.restricted_filter_helper"}}

- - + +
- +
- +
- +
- +
- +
- +
-
+
- +
- +
- +
- +
- +
- +
@@ -144,31 +144,31 @@ {{if .Source.IsLDAP}}
- +
-
- +
+
- +
{{end}}
- + -

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+

{{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

- +
@@ -178,7 +178,7 @@ {{if .Source.IsSMTP}} {{$cfg:=.Source.Cfg}}
- +
- +
- +
- +
-

{{.locale.Tr "admin.auths.force_smtps_helper"}}

+

{{ctx.Locale.Tr "admin.auths.force_smtps_helper"}}

-
+
- +
- + -

{{.locale.Tr "admin.auths.helo_hostname_helper"}}

+

{{ctx.Locale.Tr "admin.auths.helo_hostname_helper"}}

- +
- + -

{{.locale.Tr "admin.auths.allowed_domains_helper"}}

+

{{ctx.Locale.Tr "admin.auths.allowed_domains_helper"}}

- + -

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+

{{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

{{end}} @@ -240,18 +240,18 @@ {{if .Source.IsPAM}} {{$cfg:=.Source.Cfg}}
- +
- +
- + -

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+

{{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

{{end}} @@ -260,7 +260,7 @@ {{if .Source.IsOAuth2}} {{$cfg:=.Source.Cfg}}
- +
- +
- +
- +
- +
- + -

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+

{{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

- +
- +
- +
- +
- +
- +
@@ -332,37 +332,37 @@ {{end}}{{end}}
- +
- + -

{{.locale.Tr "admin.auths.oauth2_required_claim_name_helper"}}

+

{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name_helper"}}

- + -

{{.locale.Tr "admin.auths.oauth2_required_claim_value_helper"}}

+

{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_value_helper"}}

- +
- +
- +
- +
- +
{{end}} @@ -372,32 +372,32 @@ {{$cfg:=.Source.Cfg}}
- + -

{{.locale.Tr "admin.auths.sspi_auto_create_users_helper"}}

+

{{ctx.Locale.Tr "admin.auths.sspi_auto_create_users_helper"}}

- + -

{{.locale.Tr "admin.auths.sspi_auto_activate_users_helper"}}

+

{{ctx.Locale.Tr "admin.auths.sspi_auto_activate_users_helper"}}

- + -

{{.locale.Tr "admin.auths.sspi_strip_domain_names_helper"}}

+

{{ctx.Locale.Tr "admin.auths.sspi_strip_domain_names_helper"}}

- + -

{{.locale.Tr "admin.auths.sspi_separator_replacement_helper"}}

+

{{ctx.Locale.Tr "admin.auths.sspi_separator_replacement_helper"}}

- +
-

{{.locale.Tr "admin.auths.sspi_default_language_helper"}}

+

{{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}

{{end}} {{if .Source.IsLDAP}}
- +
{{end}}
- +
- - + +

- {{.locale.Tr "admin.auths.tips"}} + {{ctx.Locale.Tr "admin.auths.tips"}}

GMail Settings:

Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true

-
{{.locale.Tr "admin.auths.tips.oauth2.general"}}:
-

{{.locale.Tr "admin.auths.tips.oauth2.general.tip"}}

+
{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:
+

{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}}

diff --git a/templates/admin/auth/list.tmpl b/templates/admin/auth/list.tmpl index c9f7c0d5164..6483ec800c4 100644 --- a/templates/admin/auth/list.tmpl +++ b/templates/admin/auth/list.tmpl @@ -1,9 +1,9 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin authentication")}}

- {{.locale.Tr "admin.auths.auth_manage_panel"}} ({{.locale.Tr "admin.total" .Total}}) + {{ctx.Locale.Tr "admin.auths.auth_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})

@@ -11,12 +11,12 @@ ID - {{.locale.Tr "admin.auths.name"}} - {{.locale.Tr "admin.auths.type"}} - {{.locale.Tr "admin.auths.enabled"}} - {{.locale.Tr "admin.auths.updated"}} - {{.locale.Tr "admin.users.created"}} - {{.locale.Tr "admin.users.edit"}} + {{ctx.Locale.Tr "admin.auths.name"}} + {{ctx.Locale.Tr "admin.auths.type"}} + {{ctx.Locale.Tr "admin.auths.enabled"}} + {{ctx.Locale.Tr "admin.auths.updated"}} + {{ctx.Locale.Tr "admin.users.created"}} + {{ctx.Locale.Tr "admin.users.edit"}} diff --git a/templates/admin/auth/new.tmpl b/templates/admin/auth/new.tmpl index 37d1635c11d..f130e18f65b 100644 --- a/templates/admin/auth/new.tmpl +++ b/templates/admin/auth/new.tmpl @@ -1,7 +1,7 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new authentication")}}

- {{.locale.Tr "admin.auths.new"}} + {{ctx.Locale.Tr "admin.auths.new"}}

@@ -9,7 +9,7 @@ {{.CsrfTokenHtml}}
- +
- +
@@ -33,17 +33,17 @@ {{template "admin/auth/source/smtp" .}} -
- +
+ - +
-
+
- + -

{{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

+

{{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

@@ -55,67 +55,67 @@
- +
-
+
- +
- +
- +

- {{.locale.Tr "admin.auths.tips"}} + {{ctx.Locale.Tr "admin.auths.tips"}}

GMail Settings:

Host: smtp.gmail.com, Port: 587, Enable TLS Encryption: true

-
{{.locale.Tr "admin.auths.tips.oauth2.general"}}:
-

{{.locale.Tr "admin.auths.tips.oauth2.general.tip"}}

+
{{ctx.Locale.Tr "admin.auths.tips.oauth2.general"}}:
+

{{ctx.Locale.Tr "admin.auths.tips.oauth2.general.tip"}}

-
{{.locale.Tr "admin.auths.tip.oauth2_provider"}}
+
{{ctx.Locale.Tr "admin.auths.tip.oauth2_provider"}}
  • Bitbucket
  • - {{.locale.Tr "admin.auths.tip.bitbucket"}} + {{ctx.Locale.Tr "admin.auths.tip.bitbucket"}}
  • Dropbox
  • - {{.locale.Tr "admin.auths.tip.dropbox"}} + {{ctx.Locale.Tr "admin.auths.tip.dropbox"}}
  • Facebook
  • - {{.locale.Tr "admin.auths.tip.facebook"}} + {{ctx.Locale.Tr "admin.auths.tip.facebook"}}
  • GitHub
  • - {{.locale.Tr "admin.auths.tip.github"}} + {{ctx.Locale.Tr "admin.auths.tip.github"}}
  • GitLab
  • - {{.locale.Tr "admin.auths.tip.gitlab"}} + {{ctx.Locale.Tr "admin.auths.tip.gitlab_new"}}
  • Google
  • - {{.locale.Tr "admin.auths.tip.google_plus"}} + {{ctx.Locale.Tr "admin.auths.tip.google_plus"}}
  • OpenID Connect
  • - {{.locale.Tr "admin.auths.tip.openid_connect"}} + {{ctx.Locale.Tr "admin.auths.tip.openid_connect"}}
  • Twitter
  • - {{.locale.Tr "admin.auths.tip.twitter"}} + {{ctx.Locale.Tr "admin.auths.tip.twitter"}}
  • Discord
  • - {{.locale.Tr "admin.auths.tip.discord"}} + {{ctx.Locale.Tr "admin.auths.tip.discord"}}
  • Gitea
  • - {{.locale.Tr "admin.auths.tip.gitea"}} + {{ctx.Locale.Tr "admin.auths.tip.gitea"}}
  • Nextcloud
  • - {{.locale.Tr "admin.auths.tip.nextcloud"}} + {{ctx.Locale.Tr "admin.auths.tip.nextcloud"}}
  • Yandex
  • - {{.locale.Tr "admin.auths.tip.yandex"}} + {{ctx.Locale.Tr "admin.auths.tip.yandex"}}
  • Mastodon
  • - {{.locale.Tr "admin.auths.tip.mastodon"}} + {{ctx.Locale.Tr "admin.auths.tip.mastodon"}}
    diff --git a/templates/admin/auth/source/ldap.tmpl b/templates/admin/auth/source/ldap.tmpl index a2bd37be0c5..9754aed55a5 100644 --- a/templates/admin/auth/source/ldap.tmpl +++ b/templates/admin/auth/source/ldap.tmpl @@ -1,6 +1,6 @@ -
    +
    - +
    - +
    - +
    -
    +
    - +
    -
    - +
    +
    -
    - +
    +
    - +
    -
    - +
    +
    - +
    - +
    - + -

    {{.locale.Tr "admin.auths.restricted_filter_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.restricted_filter_helper"}}

    - - + +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    -
    +
    - +
    -
    - +
    +
    - + -

    {{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    - +
    diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl index aaa8b7fe2d6..f02c5bdf309 100644 --- a/templates/admin/auth/source/oauth.tmpl +++ b/templates/admin/auth/source/oauth.tmpl @@ -1,6 +1,6 @@ -
    +
    - +
    - +
    - +
    - +
    - +
    - + -

    {{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    - +
    - +
    - +
    - +
    - +
    - +
    @@ -73,37 +73,37 @@ {{end}}{{end}}
    - +
    - + -

    {{.locale.Tr "admin.auths.oauth2_required_claim_name_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name_helper"}}

    - + -

    {{.locale.Tr "admin.auths.oauth2_required_claim_value_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.oauth2_required_claim_value_helper"}}

    - +
    - +
    - +
    - +
    - +
    diff --git a/templates/admin/auth/source/smtp.tmpl b/templates/admin/auth/source/smtp.tmpl index e83f7afb695..31195acf658 100644 --- a/templates/admin/auth/source/smtp.tmpl +++ b/templates/admin/auth/source/smtp.tmpl @@ -1,6 +1,6 @@ -
    +
    - +
    - +
    - +
    - + -

    {{.locale.Tr "admin.auths.force_smtps_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.force_smtps_helper"}}

    - +
    - + -

    {{.locale.Tr "admin.auths.helo_hostname_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.helo_hostname_helper"}}

    - +
    - + -

    {{.locale.Tr "admin.auths.allowed_domains_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.allowed_domains_helper"}}

    - + -

    {{.locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.skip_local_two_fa_helper"}}

    diff --git a/templates/admin/auth/source/sspi.tmpl b/templates/admin/auth/source/sspi.tmpl index 9608616e1b2..6a3f00f9a8f 100644 --- a/templates/admin/auth/source/sspi.tmpl +++ b/templates/admin/auth/source/sspi.tmpl @@ -1,32 +1,32 @@ -
    +
    - + -

    {{.locale.Tr "admin.auths.sspi_auto_create_users_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.sspi_auto_create_users_helper"}}

    - + -

    {{.locale.Tr "admin.auths.sspi_auto_activate_users_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.sspi_auto_activate_users_helper"}}

    - + -

    {{.locale.Tr "admin.auths.sspi_strip_domain_names_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.sspi_strip_domain_names_helper"}}

    - + -

    {{.locale.Tr "admin.auths.sspi_separator_replacement_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.sspi_separator_replacement_helper"}}

    - +
    -

    {{.locale.Tr "admin.auths.sspi_default_language_helper"}}

    +

    {{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}

    diff --git a/templates/admin/base/search.tmpl b/templates/admin/base/search.tmpl deleted file mode 100644 index 19977f05a9b..00000000000 --- a/templates/admin/base/search.tmpl +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 36d9bcb8a5e..8c16429920b 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -1,82 +1,82 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}}

    - {{.locale.Tr "admin.config.server_config"}} + {{ctx.Locale.Tr "admin.config.server_config"}}

    -
    {{.locale.Tr "admin.config.app_name"}}
    +
    {{ctx.Locale.Tr "admin.config.app_name"}}
    {{AppName}}
    -
    {{.locale.Tr "admin.config.app_ver"}}
    +
    {{ctx.Locale.Tr "admin.config.app_ver"}}
    {{AppVer}}{{.AppBuiltWith}}
    -
    {{.locale.Tr "admin.config.custom_conf"}}
    +
    {{ctx.Locale.Tr "admin.config.custom_conf"}}
    {{.CustomConf}}
    -
    {{.locale.Tr "admin.config.app_url"}}
    +
    {{ctx.Locale.Tr "admin.config.app_url"}}
    {{.AppUrl}}
    -
    {{.locale.Tr "admin.config.domain"}}
    +
    {{ctx.Locale.Tr "admin.config.domain"}}
    {{.Domain}}
    -
    {{.locale.Tr "admin.config.offline_mode"}}
    +
    {{ctx.Locale.Tr "admin.config.offline_mode"}}
    {{if .OfflineMode}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.disable_router_log"}}
    +
    {{ctx.Locale.Tr "admin.config.disable_router_log"}}
    {{if .DisableRouterLog}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.run_user"}}
    +
    {{ctx.Locale.Tr "admin.config.run_user"}}
    {{.RunUser}}
    -
    {{.locale.Tr "admin.config.run_mode"}}
    +
    {{ctx.Locale.Tr "admin.config.run_mode"}}
    {{.RunMode}}
    -
    {{.locale.Tr "admin.config.git_version"}}
    +
    {{ctx.Locale.Tr "admin.config.git_version"}}
    {{.GitVersion}}
    -
    {{.locale.Tr "admin.config.app_data_path"}}
    +
    {{ctx.Locale.Tr "admin.config.app_data_path"}}
    {{.AppDataPath}}
    -
    {{.locale.Tr "admin.config.repo_root_path"}}
    +
    {{ctx.Locale.Tr "admin.config.repo_root_path"}}
    {{.RepoRootPath}}
    -
    {{.locale.Tr "admin.config.custom_file_root_path"}}
    +
    {{ctx.Locale.Tr "admin.config.custom_file_root_path"}}
    {{.CustomRootPath}}
    -
    {{.locale.Tr "admin.config.log_file_root_path"}}
    +
    {{ctx.Locale.Tr "admin.config.log_file_root_path"}}
    {{.LogRootPath}}
    -
    {{.locale.Tr "admin.config.script_type"}}
    +
    {{ctx.Locale.Tr "admin.config.script_type"}}
    {{.ScriptType}}
    -
    {{.locale.Tr "admin.config.reverse_auth_user"}}
    +
    {{ctx.Locale.Tr "admin.config.reverse_auth_user"}}
    {{.ReverseProxyAuthUser}}

    - {{.locale.Tr "admin.config.ssh_config"}} + {{ctx.Locale.Tr "admin.config.ssh_config"}}

    -
    {{.locale.Tr "admin.config.ssh_enabled"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_enabled"}}
    {{if not .SSH.Disabled}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{if not .SSH.Disabled}} -
    {{.locale.Tr "admin.config.ssh_start_builtin_server"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_start_builtin_server"}}
    {{if .SSH.StartBuiltinServer}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.ssh_domain"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_domain"}}
    {{.SSH.Domain}}
    -
    {{.locale.Tr "admin.config.ssh_port"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_port"}}
    {{.SSH.Port}}
    -
    {{.locale.Tr "admin.config.ssh_listen_port"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_listen_port"}}
    {{.SSH.ListenPort}}
    {{if not .SSH.StartBuiltinServer}} -
    {{.locale.Tr "admin.config.ssh_root_path"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_root_path"}}
    {{.SSH.RootPath}}
    -
    {{.locale.Tr "admin.config.ssh_key_test_path"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_key_test_path"}}
    {{.SSH.KeyTestPath}}
    -
    {{.locale.Tr "admin.config.ssh_keygen_path"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_keygen_path"}}
    {{.SSH.KeygenPath}}
    -
    {{.locale.Tr "admin.config.ssh_minimum_key_size_check"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_minimum_key_size_check"}}
    {{if .SSH.MinimumKeySizeCheck}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{if .SSH.MinimumKeySizeCheck}} -
    {{.locale.Tr "admin.config.ssh_minimum_key_sizes"}}
    +
    {{ctx.Locale.Tr "admin.config.ssh_minimum_key_sizes"}}
    {{.SSH.MinimumKeySizes}}
    {{end}} {{end}} @@ -85,160 +85,158 @@

    - {{.locale.Tr "admin.config.lfs_config"}} + {{ctx.Locale.Tr "admin.config.lfs_config"}}

    -
    {{.locale.Tr "admin.config.lfs_enabled"}}
    +
    {{ctx.Locale.Tr "admin.config.lfs_enabled"}}
    {{if .LFS.StartServer}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{if .LFS.StartServer}} -
    {{.locale.Tr "admin.config.lfs_content_path"}}
    +
    {{ctx.Locale.Tr "admin.config.lfs_content_path"}}
    {{JsonUtils.EncodeToString .LFS.Storage.ToShadowCopy}}
    -
    {{.locale.Tr "admin.config.lfs_http_auth_expiry"}}
    +
    {{ctx.Locale.Tr "admin.config.lfs_http_auth_expiry"}}
    {{.LFS.HTTPAuthExpiry}}
    {{end}}

    - {{.locale.Tr "admin.config.db_config"}} + {{ctx.Locale.Tr "admin.config.db_config"}}

    -
    {{.locale.Tr "admin.config.db_type"}}
    +
    {{ctx.Locale.Tr "admin.config.db_type"}}
    {{.DbCfg.Type}}
    {{if not (eq .DbCfg.Type "sqlite3")}} -
    {{.locale.Tr "admin.config.db_host"}}
    +
    {{ctx.Locale.Tr "admin.config.db_host"}}
    {{if .DbCfg.Host}}{{.DbCfg.Host}}{{else}}-{{end}}
    -
    {{.locale.Tr "admin.config.db_name"}}
    +
    {{ctx.Locale.Tr "admin.config.db_name"}}
    {{if .DbCfg.Name}}{{.DbCfg.Name}}{{else}}-{{end}}
    -
    {{.locale.Tr "admin.config.db_user"}}
    +
    {{ctx.Locale.Tr "admin.config.db_user"}}
    {{if .DbCfg.User}}{{.DbCfg.User}}{{else}}-{{end}}
    {{end}} {{if eq .DbCfg.Type "postgres"}} -
    {{.locale.Tr "admin.config.db_schema"}}
    +
    {{ctx.Locale.Tr "admin.config.db_schema"}}
    {{if .DbCfg.Schema}}{{.DbCfg.Schema}}{{else}}-{{end}}
    -
    {{.locale.Tr "admin.config.db_ssl_mode"}}
    +
    {{ctx.Locale.Tr "admin.config.db_ssl_mode"}}
    {{if .DbCfg.SSLMode}}{{.DbCfg.SSLMode}}{{else}}-{{end}}
    {{end}} {{if eq .DbCfg.Type "sqlite3"}} -
    {{.locale.Tr "admin.config.db_path"}}
    +
    {{ctx.Locale.Tr "admin.config.db_path"}}
    {{if .DbCfg.Path}}{{.DbCfg.Path}}{{else}}-{{end}}
    {{end}}

    - {{.locale.Tr "admin.config.service_config"}} + {{ctx.Locale.Tr "admin.config.service_config"}}

    -
    {{.locale.Tr "admin.config.register_email_confirm"}}
    +
    {{ctx.Locale.Tr "admin.config.register_email_confirm"}}
    {{if .Service.RegisterEmailConfirm}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.disable_register"}}
    +
    {{ctx.Locale.Tr "admin.config.disable_register"}}
    {{if .Service.DisableRegistration}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.allow_only_internal_registration"}}
    +
    {{ctx.Locale.Tr "admin.config.allow_only_internal_registration"}}
    {{if .Service.AllowOnlyInternalRegistration}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.allow_only_external_registration"}}
    +
    {{ctx.Locale.Tr "admin.config.allow_only_external_registration"}}
    {{if .Service.AllowOnlyExternalRegistration}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.show_registration_button"}}
    +
    {{ctx.Locale.Tr "admin.config.show_registration_button"}}
    {{if .Service.ShowRegistrationButton}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.enable_openid_signup"}}
    +
    {{ctx.Locale.Tr "admin.config.enable_openid_signup"}}
    {{if .Service.EnableOpenIDSignUp}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.enable_openid_signin"}}
    +
    {{ctx.Locale.Tr "admin.config.enable_openid_signin"}}
    {{if .Service.EnableOpenIDSignIn}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.require_sign_in_view"}}
    +
    {{ctx.Locale.Tr "admin.config.require_sign_in_view"}}
    {{if .Service.RequireSignInView}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.mail_notify"}}
    +
    {{ctx.Locale.Tr "admin.config.mail_notify"}}
    {{if .Service.EnableNotifyMail}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.disable_key_size_check"}}
    -
    {{if .SSH.MinimumKeySizeCheck}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.enable_captcha"}}
    +
    {{ctx.Locale.Tr "admin.config.enable_captcha"}}
    {{if .Service.EnableCaptcha}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.default_keep_email_private"}}
    +
    {{ctx.Locale.Tr "admin.config.default_keep_email_private"}}
    {{if .Service.DefaultKeepEmailPrivate}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.default_allow_create_organization"}}
    +
    {{ctx.Locale.Tr "admin.config.default_allow_create_organization"}}
    {{if .Service.DefaultAllowCreateOrganization}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.enable_timetracking"}}
    +
    {{ctx.Locale.Tr "admin.config.enable_timetracking"}}
    {{if .Service.EnableTimetracking}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{if .Service.EnableTimetracking}} -
    {{.locale.Tr "admin.config.default_enable_timetracking"}}
    +
    {{ctx.Locale.Tr "admin.config.default_enable_timetracking"}}
    {{if .Service.DefaultEnableTimetracking}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.default_allow_only_contributors_to_track_time"}}
    +
    {{ctx.Locale.Tr "admin.config.default_allow_only_contributors_to_track_time"}}
    {{if .Service.DefaultAllowOnlyContributorsToTrackTime}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{end}} -
    {{.locale.Tr "admin.config.default_visibility_organization"}}
    +
    {{ctx.Locale.Tr "admin.config.default_visibility_organization"}}
    {{.Service.DefaultOrgVisibility}}
    -
    {{.locale.Tr "admin.config.no_reply_address"}}
    +
    {{ctx.Locale.Tr "admin.config.no_reply_address"}}
    {{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}
    -
    {{.locale.Tr "admin.config.default_enable_dependencies"}}
    +
    {{ctx.Locale.Tr "admin.config.default_enable_dependencies"}}
    {{if .Service.DefaultEnableDependencies}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.active_code_lives"}}
    -
    {{.Service.ActiveCodeLives}} {{.locale.Tr "tool.raw_minutes"}}
    -
    {{.locale.Tr "admin.config.reset_password_code_lives"}}
    -
    {{.Service.ResetPwdCodeLives}} {{.locale.Tr "tool.raw_minutes"}}
    +
    {{ctx.Locale.Tr "admin.config.active_code_lives"}}
    +
    {{.Service.ActiveCodeLives}} {{ctx.Locale.Tr "tool.raw_minutes"}}
    +
    {{ctx.Locale.Tr "admin.config.reset_password_code_lives"}}
    +
    {{.Service.ResetPwdCodeLives}} {{ctx.Locale.Tr "tool.raw_minutes"}}

    - {{.locale.Tr "admin.config.webhook_config"}} + {{ctx.Locale.Tr "admin.config.webhook_config"}}

    -
    {{.locale.Tr "admin.config.queue_length"}}
    +
    {{ctx.Locale.Tr "admin.config.queue_length"}}
    {{.Webhook.QueueLength}}
    -
    {{.locale.Tr "admin.config.deliver_timeout"}}
    -
    {{.Webhook.DeliverTimeout}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.skip_tls_verify"}}
    +
    {{ctx.Locale.Tr "admin.config.deliver_timeout"}}
    +
    {{.Webhook.DeliverTimeout}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.skip_tls_verify"}}
    {{if .Webhook.SkipTLSVerify}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}

    - {{.locale.Tr "admin.config.mailer_config"}} + {{ctx.Locale.Tr "admin.config.mailer_config"}}

    -
    {{.locale.Tr "admin.config.mailer_enabled"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_enabled"}}
    {{if .MailerEnabled}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{if .MailerEnabled}} -
    {{.locale.Tr "admin.config.mailer_name"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_name"}}
    {{.Mailer.Name}}
    {{if eq .Mailer.Protocol "sendmail"}} -
    {{.locale.Tr "admin.config.mailer_use_sendmail"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_use_sendmail"}}
    {{svg "octicon-check"}}
    -
    {{.locale.Tr "admin.config.mailer_sendmail_path"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_sendmail_path"}}
    {{.Mailer.SendmailPath}}
    -
    {{.locale.Tr "admin.config.mailer_sendmail_args"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_sendmail_args"}}
    {{.Mailer.SendmailArgs}}
    -
    {{.locale.Tr "admin.config.mailer_sendmail_timeout"}}
    -
    {{.Mailer.SendmailTimeout}} {{.locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_sendmail_timeout"}}
    +
    {{.Mailer.SendmailTimeout}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    {{else if eq .Mailer.Protocol "dummy"}} -
    {{.locale.Tr "admin.config.mailer_use_dummy"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_use_dummy"}}
    {{svg "octicon-check"}}
    {{else}}{{/* SMTP family */}} -
    {{.locale.Tr "admin.config.mailer_protocol"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_protocol"}}
    {{.Mailer.Protocol}}
    -
    {{.locale.Tr "admin.config.mailer_enable_helo"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_enable_helo"}}
    {{if .Mailer.EnableHelo}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.mailer_smtp_addr"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_smtp_addr"}}
    {{.Mailer.SMTPAddr}}
    -
    {{.locale.Tr "admin.config.mailer_smtp_port"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_smtp_port"}}
    {{.Mailer.SMTPPort}}
    {{end}} -
    {{.locale.Tr "admin.config.mailer_user"}}
    +
    {{ctx.Locale.Tr "admin.config.mailer_user"}}
    {{if .Mailer.User}}{{.Mailer.User}}{{else}}(empty){{end}}
    -
    {{.locale.Tr "admin.config.send_test_mail"}}
    +
    {{ctx.Locale.Tr "admin.config.send_test_mail"}}
    {{.CsrfTokenHtml}}
    - +
    - +
    {{end}} @@ -246,118 +244,97 @@

    - {{.locale.Tr "admin.config.cache_config"}} + {{ctx.Locale.Tr "admin.config.cache_config"}}

    -
    {{.locale.Tr "admin.config.cache_adapter"}}
    +
    {{ctx.Locale.Tr "admin.config.cache_adapter"}}
    {{.CacheAdapter}}
    {{if eq .CacheAdapter "memory"}} -
    {{.locale.Tr "admin.config.cache_interval"}}
    -
    {{.CacheInterval}} {{.locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.cache_interval"}}
    +
    {{.CacheInterval}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    {{end}} {{if .CacheConn}} -
    {{.locale.Tr "admin.config.cache_conn"}}
    +
    {{ctx.Locale.Tr "admin.config.cache_conn"}}
    {{.CacheConn}}
    -
    {{.locale.Tr "admin.config.cache_item_ttl"}}
    +
    {{ctx.Locale.Tr "admin.config.cache_item_ttl"}}
    {{.CacheItemTTL}}
    {{end}}

    - {{.locale.Tr "admin.config.session_config"}} + {{ctx.Locale.Tr "admin.config.session_config"}}

    -
    {{.locale.Tr "admin.config.session_provider"}}
    +
    {{ctx.Locale.Tr "admin.config.session_provider"}}
    {{.SessionConfig.Provider}}
    -
    {{.locale.Tr "admin.config.provider_config"}}
    +
    {{ctx.Locale.Tr "admin.config.provider_config"}}
    {{if .SessionConfig.ProviderConfig}}{{.SessionConfig.ProviderConfig}}{{else}}-{{end}}
    -
    {{.locale.Tr "admin.config.cookie_name"}}
    +
    {{ctx.Locale.Tr "admin.config.cookie_name"}}
    {{.SessionConfig.CookieName}}
    -
    {{.locale.Tr "admin.config.gc_interval_time"}}
    -
    {{.SessionConfig.Gclifetime}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.session_life_time"}}
    -
    {{.SessionConfig.Maxlifetime}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.https_only"}}
    +
    {{ctx.Locale.Tr "admin.config.gc_interval_time"}}
    +
    {{.SessionConfig.Gclifetime}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.session_life_time"}}
    +
    {{.SessionConfig.Maxlifetime}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.https_only"}}
    {{if .SessionConfig.Secure}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}

    - {{.locale.Tr "admin.config.picture_config"}} + {{ctx.Locale.Tr "admin.config.git_config"}}

    -
    {{.locale.Tr "admin.config.disable_gravatar"}}
    -
    -
    - -
    -
    -
    -
    {{.locale.Tr "admin.config.enable_federated_avatar"}}
    -
    -
    - -
    -
    -
    -
    - -

    - {{.locale.Tr "admin.config.git_config"}} -

    -
    -
    -
    {{.locale.Tr "admin.config.git_disable_diff_highlight"}}
    +
    {{ctx.Locale.Tr "admin.config.git_disable_diff_highlight"}}
    {{if .Git.DisableDiffHighlight}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    -
    {{.locale.Tr "admin.config.git_max_diff_lines"}}
    +
    {{ctx.Locale.Tr "admin.config.git_max_diff_lines"}}
    {{.Git.MaxGitDiffLines}}
    -
    {{.locale.Tr "admin.config.git_max_diff_line_characters"}}
    +
    {{ctx.Locale.Tr "admin.config.git_max_diff_line_characters"}}
    {{.Git.MaxGitDiffLineCharacters}}
    -
    {{.locale.Tr "admin.config.git_max_diff_files"}}
    +
    {{ctx.Locale.Tr "admin.config.git_max_diff_files"}}
    {{.Git.MaxGitDiffFiles}}
    -
    {{.locale.Tr "admin.config.git_gc_args"}}
    +
    {{ctx.Locale.Tr "admin.config.git_gc_args"}}
    {{.Git.GCArgs}}
    -
    {{.locale.Tr "admin.config.git_migrate_timeout"}}
    -
    {{.Git.Timeout.Migrate}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.git_mirror_timeout"}}
    -
    {{.Git.Timeout.Mirror}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.git_clone_timeout"}}
    -
    {{.Git.Timeout.Clone}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.git_pull_timeout"}}
    -
    {{.Git.Timeout.Pull}} {{.locale.Tr "tool.raw_seconds"}}
    -
    {{.locale.Tr "admin.config.git_gc_timeout"}}
    -
    {{.Git.Timeout.GC}} {{.locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.git_migrate_timeout"}}
    +
    {{.Git.Timeout.Migrate}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.git_mirror_timeout"}}
    +
    {{.Git.Timeout.Mirror}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.git_clone_timeout"}}
    +
    {{.Git.Timeout.Clone}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.git_pull_timeout"}}
    +
    {{.Git.Timeout.Pull}} {{ctx.Locale.Tr "tool.raw_seconds"}}
    +
    {{ctx.Locale.Tr "admin.config.git_gc_timeout"}}
    +
    {{.Git.Timeout.GC}} {{ctx.Locale.Tr "tool.raw_seconds"}}

    - {{.locale.Tr "admin.config.log_config"}} + {{ctx.Locale.Tr "admin.config.log_config"}}

    {{if .Loggers.xorm.IsEnabled}} -
    {{$.locale.Tr "admin.config.xorm_log_sql"}}
    +
    {{ctx.Locale.Tr "admin.config.xorm_log_sql"}}
    {{if $.LogSQL}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
    {{end}} {{if .Loggers.access.IsEnabled}} -
    {{$.locale.Tr "admin.config.access_log_template"}}
    +
    {{ctx.Locale.Tr "admin.config.access_log_template"}}
    {{$.AccessLogTemplate}}
    {{end}} {{range $loggerName, $loggerDetail := .Loggers}} -
    {{$.locale.Tr "admin.config.logger_name_fmt" $loggerName}}
    +
    {{ctx.Locale.Tr "admin.config.logger_name_fmt" $loggerName}}
    {{if $loggerDetail.IsEnabled}} -
    {{$loggerDetail.EventWriters | JsonUtils.EncodeToString | JsonUtils.PrettyIndent}}
    +
    {{$loggerDetail.EventWriters | JsonUtils.EncodeToString | JsonUtils.PrettyIndent}}
    {{else}} -
    {{$.locale.Tr "admin.config.disabled_logger"}}
    +
    {{ctx.Locale.Tr "admin.config.disabled_logger"}}
    {{end}} {{end}}
    diff --git a/templates/admin/config_settings.tmpl b/templates/admin/config_settings.tmpl new file mode 100644 index 00000000000..02ab5fd0fbe --- /dev/null +++ b/templates/admin/config_settings.tmpl @@ -0,0 +1,42 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +

    + {{ctx.Locale.Tr "admin.config.picture_config"}} +

    +
    +
    +
    {{ctx.Locale.Tr "admin.config.disable_gravatar"}}
    +
    +
    + +
    +
    +
    +
    {{ctx.Locale.Tr "admin.config.enable_federated_avatar"}}
    +
    +
    + +
    +
    +
    +
    + +

    + {{ctx.Locale.Tr "repository"}} +

    +
    +
    +
    +
    + {{ctx.Locale.Tr "admin.config.open_with_editor_app_help"}} +
    {{.DefaultOpenWithEditorAppsString}}
    +
    +
    +
    + +
    +
    + +
    +
    +
    +{{template "admin/layout_footer" .}} diff --git a/templates/admin/cron.tmpl b/templates/admin/cron.tmpl index c1546194351..3cb641488cc 100644 --- a/templates/admin/cron.tmpl +++ b/templates/admin/cron.tmpl @@ -1,32 +1,32 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monitor")}}

    - {{.locale.Tr "admin.monitor.cron"}} + {{ctx.Locale.Tr "admin.monitor.cron"}}

    - +
    - - - - - - + + + + + + {{range .Entries}} - - + + - + {{end}} diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl index 8312fba039a..589fc5048af 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -2,68 +2,70 @@
    {{if .NeedUpdate}}
    -

    {{(.locale.Tr "admin.dashboard.new_version_hint" .RemoteVersion AppVer) | Str2html}}

    +

    {{ctx.Locale.Tr "admin.dashboard.new_version_hint" .RemoteVersion AppVer}}

    {{end}}

    - {{.locale.Tr "admin.dashboard.operations"}} + {{ctx.Locale.Tr "admin.dashboard.maintenance_operations"}}

    {{.CsrfTokenHtml}} -
    {{.locale.Tr "admin.monitor.name"}}{{.locale.Tr "admin.monitor.schedule"}}{{.locale.Tr "admin.monitor.next"}}{{.locale.Tr "admin.monitor.previous"}}{{.locale.Tr "admin.monitor.execute_times"}}{{.locale.Tr "admin.monitor.last_execution_result"}}{{ctx.Locale.Tr "admin.monitor.name"}}{{ctx.Locale.Tr "admin.monitor.schedule"}}{{ctx.Locale.Tr "admin.monitor.next"}}{{ctx.Locale.Tr "admin.monitor.previous"}}{{ctx.Locale.Tr "admin.monitor.execute_times"}}{{ctx.Locale.Tr "admin.monitor.last_execution_result"}}
    {{$.locale.Tr (printf "admin.dashboard.%s" .Name)}}{{ctx.Locale.Tr (printf "admin.dashboard.%s" .Name)}} {{.Spec}} {{DateTime "full" .Next}} {{if gt .Prev.Year 1}}{{DateTime "full" .Prev}}{{else}}-{{end}} {{.ExecTimes}}{{if eq .Status ""}}—{{else if eq .Status "finished"}}{{svg "octicon-check" 16}}{{else}}{{svg "octicon-x" 16}}{{end}}{{if eq .Status ""}}—{{else if eq .Status "finished"}}{{svg "octicon-check" 16}}{{else}}{{svg "octicon-x" 16}}{{end}}
    +
    - - + + - - + + - - + + - - + + {{if and (not .SSH.Disabled) (not .SSH.StartBuiltinServer)}} - - + + - - + + {{end}} - - + + - - + + - - + + - - + + - - + + - - + + + + + +
    {{.locale.Tr "admin.dashboard.delete_inactive_accounts"}}{{ctx.Locale.Tr "admin.dashboard.delete_inactive_accounts"}}
    {{.locale.Tr "admin.dashboard.delete_repo_archives"}}{{ctx.Locale.Tr "admin.dashboard.delete_repo_archives"}}
    {{.locale.Tr "admin.dashboard.delete_missing_repos"}}{{ctx.Locale.Tr "admin.dashboard.delete_missing_repos"}}
    {{.locale.Tr "admin.dashboard.git_gc_repos"}}{{ctx.Locale.Tr "admin.dashboard.git_gc_repos"}}
    {{.locale.Tr "admin.dashboard.resync_all_sshkeys"}}
    - {{.locale.Tr "admin.dashboard.resync_all_sshkeys.desc"}}
    {{ctx.Locale.Tr "admin.dashboard.resync_all_sshkeys"}}
    {{.locale.Tr "admin.dashboard.resync_all_sshprincipals"}}
    - {{.locale.Tr "admin.dashboard.resync_all_sshprincipals.desc"}}
    {{ctx.Locale.Tr "admin.dashboard.resync_all_sshprincipals"}}
    {{.locale.Tr "admin.dashboard.resync_all_hooks"}}{{ctx.Locale.Tr "admin.dashboard.resync_all_hooks"}}
    {{.locale.Tr "admin.dashboard.reinit_missing_repos"}}{{ctx.Locale.Tr "admin.dashboard.reinit_missing_repos"}}
    {{.locale.Tr "admin.dashboard.sync_external_users"}}{{ctx.Locale.Tr "admin.dashboard.sync_external_users"}}
    {{.locale.Tr "admin.dashboard.repo_health_check"}}{{ctx.Locale.Tr "admin.dashboard.repo_health_check"}}
    {{.locale.Tr "admin.dashboard.delete_generated_repository_avatars"}}{{ctx.Locale.Tr "admin.dashboard.delete_generated_repository_avatars"}}
    {{.locale.Tr "admin.dashboard.sync_repo_branches"}}{{ctx.Locale.Tr "admin.dashboard.sync_repo_branches"}}
    {{ctx.Locale.Tr "admin.dashboard.sync_repo_tags"}}
    @@ -71,71 +73,11 @@

    - {{.locale.Tr "admin.dashboard.system_status"}} + {{ctx.Locale.Tr "admin.dashboard.system_status"}}

    -
    -
    -
    {{.locale.Tr "admin.dashboard.server_uptime"}}
    -
    {{.SysStatus.StartTime}}
    -
    {{.locale.Tr "admin.dashboard.current_goroutine"}}
    -
    {{.SysStatus.NumGoroutine}}
    -
    -
    {{.locale.Tr "admin.dashboard.current_memory_usage"}}
    -
    {{.SysStatus.MemAllocated}}
    -
    {{.locale.Tr "admin.dashboard.total_memory_allocated"}}
    -
    {{.SysStatus.MemTotal}}
    -
    {{.locale.Tr "admin.dashboard.memory_obtained"}}
    -
    {{.SysStatus.MemSys}}
    -
    {{.locale.Tr "admin.dashboard.pointer_lookup_times"}}
    -
    {{.SysStatus.Lookups}}
    -
    {{.locale.Tr "admin.dashboard.memory_allocate_times"}}
    -
    {{.SysStatus.MemMallocs}}
    -
    {{.locale.Tr "admin.dashboard.memory_free_times"}}
    -
    {{.SysStatus.MemFrees}}
    -
    -
    {{.locale.Tr "admin.dashboard.current_heap_usage"}}
    -
    {{.SysStatus.HeapAlloc}}
    -
    {{.locale.Tr "admin.dashboard.heap_memory_obtained"}}
    -
    {{.SysStatus.HeapSys}}
    -
    {{.locale.Tr "admin.dashboard.heap_memory_idle"}}
    -
    {{.SysStatus.HeapIdle}}
    -
    {{.locale.Tr "admin.dashboard.heap_memory_in_use"}}
    -
    {{.SysStatus.HeapInuse}}
    -
    {{.locale.Tr "admin.dashboard.heap_memory_released"}}
    -
    {{.SysStatus.HeapReleased}}
    -
    {{.locale.Tr "admin.dashboard.heap_objects"}}
    -
    {{.SysStatus.HeapObjects}}
    -
    -
    {{.locale.Tr "admin.dashboard.bootstrap_stack_usage"}}
    -
    {{.SysStatus.StackInuse}}
    -
    {{.locale.Tr "admin.dashboard.stack_memory_obtained"}}
    -
    {{.SysStatus.StackSys}}
    -
    {{.locale.Tr "admin.dashboard.mspan_structures_usage"}}
    -
    {{.SysStatus.MSpanInuse}}
    -
    {{.locale.Tr "admin.dashboard.mspan_structures_obtained"}}
    -
    {{.SysStatus.MSpanSys}}
    -
    {{.locale.Tr "admin.dashboard.mcache_structures_usage"}}
    -
    {{.SysStatus.MCacheInuse}}
    -
    {{.locale.Tr "admin.dashboard.mcache_structures_obtained"}}
    -
    {{.SysStatus.MCacheSys}}
    -
    {{.locale.Tr "admin.dashboard.profiling_bucket_hash_table_obtained"}}
    -
    {{.SysStatus.BuckHashSys}}
    -
    {{.locale.Tr "admin.dashboard.gc_metadata_obtained"}}
    -
    {{.SysStatus.GCSys}}
    -
    {{.locale.Tr "admin.dashboard.other_system_allocation_obtained"}}
    -
    {{.SysStatus.OtherSys}}
    -
    -
    {{.locale.Tr "admin.dashboard.next_gc_recycle"}}
    -
    {{.SysStatus.NextGC}}
    -
    {{.locale.Tr "admin.dashboard.last_gc_time"}}
    -
    {{.SysStatus.LastGC}}
    -
    {{.locale.Tr "admin.dashboard.total_gc_pause"}}
    -
    {{.SysStatus.PauseTotalNs}}
    -
    {{.locale.Tr "admin.dashboard.last_gc_pause"}}
    -
    {{.SysStatus.PauseNs}}
    -
    {{.locale.Tr "admin.dashboard.gc_times"}}
    -
    {{.SysStatus.NumGC}}
    -
    + {{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}} +
    + {{template "admin/system_status" .}}
    {{template "admin/layout_footer" .}} diff --git a/templates/admin/emails/list.tmpl b/templates/admin/emails/list.tmpl index 153877174bf..388863df9b2 100644 --- a/templates/admin/emails/list.tmpl +++ b/templates/admin/emails/list.tmpl @@ -1,27 +1,24 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}

    - {{.locale.Tr "admin.emails.email_manage_panel"}} ({{.locale.Tr "admin.total" .Total}}) + {{ctx.Locale.Tr "admin.emails.email_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})

    -