diff --git a/.air.toml b/.air.toml index d13f8c4f99..de97bd8b29 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 657dfa1c0e..bfdee0c0ca 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 9e290fb6a5..d391cf78cf 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 80cbeb040c..b299c7313d 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 d05a96ce64..0000000000 --- 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 dd2c32eec0..43edd14cec 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,12 +527,10 @@ 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] @@ -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] @@ -663,6 +743,7 @@ rules: 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 +818,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 624a2d97db..1447a6ea32 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 1004c55de3..94c1bd0ab7 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -1,91 +1,91 @@ name: Bug Report description: Found something you weren't expecting? Report it here! -labels: ["kind/bug"] +labels: ["type/bug"] body: -- type: markdown - attributes: - value: | - NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. -- type: markdown - attributes: - value: | - 1. Please speak English, this is the language all maintainers can speak and write. - 2. Please ask questions or configuration/deploy problems on our Discord - server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). - 3. Make sure you are using the latest release and - take a moment to check that your issue hasn't been reported before. - 4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq) - 5. It's really important to provide pertinent details and logs (https://docs.gitea.com/help/support), - incomplete details will be handled as an invalid report. -- type: textarea - id: description - attributes: - label: Description - description: | - Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below) - If you are using a proxy or a CDN (e.g. Cloudflare) in front of Gitea, please disable the proxy/CDN fully and access Gitea directly to confirm the issue still persists without those services. -- type: input - id: gitea-ver - attributes: - label: Gitea Version - description: Gitea version (or commit reference) of your instance - validations: - required: true -- type: dropdown - id: can-reproduce - attributes: - label: Can you reproduce the bug on the Gitea demo site? - description: | - If so, please provide a URL in the Description field - URL of Gitea demo: https://try.gitea.io - options: - - "Yes" - - "No" - validations: - required: true -- type: markdown - attributes: - value: | - It's really important to provide pertinent logs - Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help - In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini -- type: input - id: logs - attributes: - label: Log Gist - description: Please provide a gist URL of your logs, with any sensitive information (e.g. API keys) removed/hidden -- type: textarea - id: screenshots - attributes: - label: Screenshots - description: If this issue involves the Web Interface, please provide one or more screenshots -- type: input - id: git-ver - attributes: - label: Git Version - description: The version of git running on the server -- type: input - id: os-ver - attributes: - label: Operating System - description: The operating system you are using to run Gitea -- type: textarea - id: run-info - attributes: - label: How are you running Gitea? - description: | - Please include information on whether you built Gitea yourself, used one of our downloads, are using https://try.gitea.io or are using some other package - Please also tell us how you are running Gitea, e.g. if it is being run from docker, a command-line, systemd etc. - If you are using a package or systemd tell us what distribution you are using - validations: - required: true -- type: dropdown - id: database - attributes: - label: Database - description: What database system are you running? - options: - - PostgreSQL - - MySQL/MariaDB - - MSSQL - - SQLite + - type: markdown + attributes: + value: | + NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. + - type: markdown + attributes: + value: | + 1. Please speak English, this is the language all maintainers can speak and write. + 2. Please ask questions or configuration/deploy problems on our Discord + server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). + 3. Make sure you are using the latest release and + take a moment to check that your issue hasn't been reported before. + 4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq) + 5. It's really important to provide pertinent details and logs (https://docs.gitea.com/help/support), + incomplete details will be handled as an invalid report. + - type: textarea + id: description + attributes: + label: Description + description: | + Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below) + If you are using a proxy or a CDN (e.g. Cloudflare) in front of Gitea, please disable the proxy/CDN fully and access Gitea directly to confirm the issue still persists without those services. + - type: input + id: gitea-ver + attributes: + label: Gitea Version + description: Gitea version (or commit reference) of your instance + validations: + required: true + - type: dropdown + id: can-reproduce + attributes: + label: Can you reproduce the bug on the Gitea demo site? + description: | + If so, please provide a URL in the Description field + URL of Gitea demo: https://try.gitea.io + options: + - "Yes" + - "No" + validations: + required: true + - type: markdown + attributes: + value: | + It's really important to provide pertinent logs + Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help + In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini + - type: input + id: logs + attributes: + label: Log Gist + description: Please provide a gist URL of your logs, with any sensitive information (e.g. API keys) removed/hidden + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If this issue involves the Web Interface, please provide one or more screenshots + - type: input + id: git-ver + attributes: + label: Git Version + description: The version of git running on the server + - type: input + id: os-ver + attributes: + label: Operating System + description: The operating system you are using to run Gitea + - type: textarea + id: run-info + attributes: + label: How are you running Gitea? + description: | + Please include information on whether you built Gitea yourself, used one of our downloads, are using https://try.gitea.io or are using some other package + Please also tell us how you are running Gitea, e.g. if it is being run from docker, a command-line, systemd etc. + If you are using a package or systemd tell us what distribution you are using + validations: + required: true + - type: dropdown + id: database + attributes: + label: Database + description: What database system are you running? + options: + - PostgreSQL + - MySQL/MariaDB + - MSSQL + - SQLite diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index b481e0c2de..3c9953019d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,24 +1,24 @@ 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: - value: | - 1. Please speak English, this is the language all maintainers can speak and write. - 2. Please ask questions or configuration/deploy problems on our Discord - server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). - 3. Please take a moment to check that your feature hasn't already been suggested. -- type: textarea - id: description - attributes: - label: Feature Description - placeholder: | - I think it would be great if Gitea had... - validations: - required: true -- type: textarea - id: screenshots - attributes: - label: Screenshots - description: If you can, provide screenshots of an implementation on another site e.g. GitHub + - type: markdown + attributes: + value: | + 1. Please speak English, this is the language all maintainers can speak and write. + 2. Please ask questions or configuration/deploy problems on our Discord + server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). + 3. Please take a moment to check that your feature hasn't already been suggested. + - type: textarea + id: description + attributes: + label: Feature Description + placeholder: | + I think it would be great if Gitea had... + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If you can, provide screenshots of an implementation on another site e.g. GitHub diff --git a/.github/ISSUE_TEMPLATE/ui.bug-report.yaml b/.github/ISSUE_TEMPLATE/ui.bug-report.yaml index d5c41bb836..387aee897b 100644 --- a/.github/ISSUE_TEMPLATE/ui.bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/ui.bug-report.yaml @@ -1,66 +1,66 @@ 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: - value: | - NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. -- type: markdown - attributes: - value: | - 1. Please speak English, this is the language all maintainers can speak and write. - 2. Please ask questions or configuration/deploy problems on our Discord - server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). - 3. Please take a moment to check that your issue doesn't already exist. - 4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq) - 5. Please give all relevant information below for bug reports, because - incomplete details will be handled as an invalid report. - 6. In particular it's really important to provide pertinent logs. If you are certain that this is a javascript - error, show us the javascript console. If the error appears to relate to Gitea the server you must also give us - DEBUG level logs. (See https://docs.gitea.com/administration/logging-config#collecting-logs-for-help) -- type: textarea - id: description - attributes: - label: Description - description: | - Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below) - If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please disable the proxy/CDN fully and connect to gitea directly to confirm the issue still persists without those services. -- type: textarea - id: screenshots - attributes: - label: Screenshots - description: Please provide at least 1 screenshot showing the issue. - validations: - required: true -- type: input - id: gitea-ver - attributes: - label: Gitea Version - description: Gitea version (or commit reference) your instance is running - validations: - required: true -- type: dropdown - id: can-reproduce - attributes: - label: Can you reproduce the bug on the Gitea demo site? - description: | - If so, please provide a URL in the Description field - URL of Gitea demo: https://try.gitea.io - options: - - "Yes" - - "No" - validations: - required: true -- type: input - id: os-ver - attributes: - label: Operating System - description: The operating system you are using to access Gitea -- type: input - id: browser-ver - attributes: - label: Browser Version - description: The browser and version that you are using to access Gitea - validations: - required: true + - type: markdown + attributes: + value: | + NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. + - type: markdown + attributes: + value: | + 1. Please speak English, this is the language all maintainers can speak and write. + 2. Please ask questions or configuration/deploy problems on our Discord + server (https://discord.gg/gitea) or forum (https://discourse.gitea.io). + 3. Please take a moment to check that your issue doesn't already exist. + 4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq) + 5. Please give all relevant information below for bug reports, because + incomplete details will be handled as an invalid report. + 6. In particular it's really important to provide pertinent logs. If you are certain that this is a javascript + error, show us the javascript console. If the error appears to relate to Gitea the server you must also give us + DEBUG level logs. (See https://docs.gitea.com/administration/logging-config#collecting-logs-for-help) + - type: textarea + id: description + attributes: + label: Description + description: | + Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below) + If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please disable the proxy/CDN fully and connect to gitea directly to confirm the issue still persists without those services. + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please provide at least 1 screenshot showing the issue. + validations: + required: true + - type: input + id: gitea-ver + attributes: + label: Gitea Version + description: Gitea version (or commit reference) your instance is running + validations: + required: true + - type: dropdown + id: can-reproduce + attributes: + label: Can you reproduce the bug on the Gitea demo site? + description: | + If so, please provide a URL in the Description field + URL of Gitea demo: https://try.gitea.io + options: + - "Yes" + - "No" + validations: + required: true + - type: input + id: os-ver + attributes: + label: Operating System + description: The operating system you are using to access Gitea + - type: input + id: browser-ver + attributes: + label: Browser Version + description: The browser and version that you are using to access Gitea + validations: + required: true diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..023fb05a29 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +self-hosted-runner: + labels: + - actuated-4cpu-8gb + - actuated-4cpu-16gb + - nscloud diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..d1b4d00d80 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,77 @@ +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 6a9f341cbf..0000000000 --- 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 acdf7cd364..cd8386ecc5 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 935f926cce..0000000000 --- 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 3f147c685d..f1b51debf1 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 0000000000..8abe8891c7 --- /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 f9156d668d..9a609e0551 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -17,6 +17,8 @@ on: value: ${{ jobs.detect.outputs.docker }} swagger: value: ${{ jobs.detect.outputs.swagger }} + yaml: + value: ${{ jobs.detect.outputs.yaml }} jobs: detect: @@ -30,9 +32,10 @@ jobs: templates: ${{ steps.changes.outputs.templates }} docker: ${{ steps.changes.outputs.docker }} 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: | @@ -43,6 +46,9 @@ jobs: - "go.mod" - "go.sum" - "Makefile" + - ".golangci.yml" + - ".editorconfig" + - "options/locale/locale_en-US.ini" frontend: - "**/*.js" @@ -51,16 +57,25 @@ jobs: - "package.json" - "package-lock.json" - "Makefile" + - ".eslintrc.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" docker: @@ -72,3 +87,13 @@ jobs: swagger: - "templates/swagger/v1_json.tmpl" - "Makefile" + - "package.json" + - "package-lock.json" + - ".spectral.yaml" + + yaml: + - "**/*.yml" + - "**/*.yaml" + - ".yamllint.yaml" + - "pyproject.toml" + - "poetry.lock" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 45dd77fd92..99a69ab174 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,35 +31,64 @@ 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: + if: needs.files-changed.outputs.yaml == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install poetry + - run: make deps-py + - run: make lint-yaml + lint-swagger: if: needs.files-changed.outputs.swagger == 'true' 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 @@ -73,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 @@ -88,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 @@ -101,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 @@ -116,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 @@ -148,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 @@ -161,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 50c92a9e9b..61c0391509 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: yes - 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: yes - MYSQL_DATABASE: test + MYSQL_ALLOW_EMPTY_PASSWORD: true + 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: yes - 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 61f1fd5632..f74277de67 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 7b950bfd38..5a249db9f8 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 new file mode 100644 index 0000000000..812819b599 --- /dev/null +++ b/.github/workflows/pull-labeler.yml @@ -0,0 +1,20 @@ +name: labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + labeler: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 8387f615d9..80e6683919 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -1,22 +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: ubuntu-latest + 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 @@ -26,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 }} @@ -42,24 +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 }} - nightly-docker: + run: | + aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress + nightly-docker-rootful: 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: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - name: Get cleaned branch name id: clean_name run: | @@ -71,19 +81,51 @@ 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: 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: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Get cleaned branch name + id: clean_name + run: | + # if main then say nightly otherwise cleanup name + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "branch=nightly" >> "$GITHUB_OUTPUT" + exit 0 + fi + 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@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 0000000000..12d1e1e4be --- /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 0000000000..e0e93633e8 --- /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 6b699e0870..501fef7dcf 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-* @@ -75,7 +76,7 @@ cpu.out /public/assets/js /public/assets/css /public/assets/fonts -/public/assets/img/webpack +/public/assets/licenses.txt /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* @@ -94,6 +95,7 @@ cpu.out /.go-licenses # Snapcraft +/gitea_a*.txt snap/.snapcraft/ parts/ stage/ diff --git a/.gitpod.yml b/.gitpod.yml index 35b22c45ae..f573d55a76 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 069dc13c99..d6ce37f49a 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 5c945ab981..5b96dabd38 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 7ccdd53e89..b251ff796c 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 f30d79d71f..0000000000 --- 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 new file mode 100644 index 0000000000..5a1e1e8751 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,44 @@ +extends: default + +rules: + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: 0 + max-spaces-inside-empty: 0 + + brackets: + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: 0 + max-spaces-inside-empty: 0 + + comments: + require-starting-space: true + ignore-shebangs: true + min-spaces-from-content: 1 + + comments-indentation: + level: error + + document-start: + level: error + present: false + + document-end: + present: false + + empty-lines: + max: 1 + + indentation: + spaces: 2 + + line-length: disable + + truthy: + allowed-values: ["true", "false", "on", "off"] + +ignore: | + .venv + node_modules diff --git a/BSDmakefile b/BSDmakefile index 13174f8fd2..79696eadcf 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 0b4f17b658..e119d0bec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,743 @@ 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 + * Check blocklist for emails when adding them to account (#26812) (#26831) +* ENHANCEMENTS + * Add `branch_filter` to hooks API endpoints (#26599) (#26632) + * Fix incorrect "tabindex" attributes (#26733) (#26734) + * Use line-height: normal by default (#26635) (#26708) + * Fix unable to display individual-level project (#26198) (#26636) +* BUGFIXES + * Fix wrong review requested number (#26784) (#26880) + * Avoid double-unescaping of form value (#26853) (#26863) + * Redirect from `{repo}/issues/new` to `{repo}/issues/new/choose` when blank issues are disabled (#26813) (#26847) + * Sync tags when adopting repos (#26816) (#26834) + * Fix verifyCommits error when push a new branch (#26664) (#26810) + * Include the GITHUB_TOKEN/GITEA_TOKEN secret for fork pull requests (#26759) (#26806) + * Fix some slice append usages (#26778) (#26798) + * Add fix incorrect can_create_org_repo for org owner team (#26683) (#26791) + * Fix bug for ctx usage (#26763) + * Make issue template field template access correct template data (#26698) (#26709) + * Use correct minio error (#26634) (#26639) + * Ignore the trailing slashes when comparing oauth2 redirect_uri (#26597) (#26618) + * Set errwriter for urfave/cli v1 (#26616) + * Fix reopen logic for agit flow pull request (#26399) (#26613) + * Fix context filter has no effect in dashboard (#26695) (#26811) + * Fix being unable to use a repo that prohibits accepting PRs as a PR source. (#26785) (#26790) + * Fix Page Not Found error (#26768) + ## [1.20.3](https://github.com/go-gitea/gitea/releases/tag/v1.20.3) - 2023-08-20 * BREAKING @@ -400,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 5cd83a4898..5d20bc2589 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: + +- `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 - 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` - -are oftentimes notable labels. +- 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 b42b4daa5f..b647c0cd59 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 449e630fad..dd7da97278 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 2d55254b03..eed87529a3 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -55,3 +55,9 @@ Philip Peterson (@philip-peterson) Denys Konovalov (@denyskon) Punit Inani (@puni9869) CaiCandong <1290147055@qq.com> (@caicandong) +Rui Chen (@chenrui333) +Nanguan Lin (@lng2020) +kerwin612 (@kerwin612) +Gary Wang (@BLumia) +Tim-Niclas Oelschläger (@zokkis) +Yu Liu <1240335630@qq.com> (@HEREYUA) diff --git a/Makefile b/Makefile index 908ee7a337..8489520920 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 @@ -218,6 +221,9 @@ help: @echo " - lint-md lint markdown files" @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" @@ -225,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" @@ -276,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: @@ -307,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) @@ -350,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 @@ -372,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 @@ -394,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 @@ -417,19 +427,24 @@ 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 +lint-yaml: .venv + @poetry run yamllint . + .PHONY: watch watch: - @bash build/watch.sh + @bash tools/watch.sh .PHONY: watch-frontend watch-frontend: node-check node_modules @@ -545,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' \ @@ -610,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% @@ -638,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 @@ -689,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 @@ -712,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 @@ -734,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 + GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) -.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 - -.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 @@ -771,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 @@ -782,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 @@ -880,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 @@ -916,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 @@ -930,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 @@ -939,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 @@ -957,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 @@ -1000,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: @@ -1018,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 767a7858e4..f579449174 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,18 @@ -

- - Gitea - -

-

Gitea - Git with a cup of tea

+# Gitea -

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

+[![](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 -

+[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 @@ -121,8 +84,6 @@ If you have questions that are not covered by the documentation, you can get in We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea). -The Hugo-based documentation theme is hosted at [gitea/theme](https://gitea.com/gitea/theme). - The official Gitea CLI is developed at [gitea/tea](https://gitea.com/gitea/tea). ## Authors @@ -175,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 866b85e999..726c4273a6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,64 +1,28 @@ -

- - Gitea - -

-

Gitea - Git with a cup of tea

+# Gitea -

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

+[![](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 -

+[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 5bee4a7f39..4c7d437844 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 8a17148e1b..be9022b694 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/build/backport-locales.go b/build/backport-locales.go index 0346215348..d112dd72bd 100644 --- a/build/backport-locales.go +++ b/build/backport-locales.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/setting" ) @@ -58,7 +59,7 @@ func main() { // use old en-US as the base, and copy the new translations to the old locales enUsOld := inisOld["options/locale/locale_en-US.ini"] - brokenWarned := map[string]bool{} + brokenWarned := make(container.Set[string]) for path, iniOld := range inisOld { if iniOld == enUsOld { continue @@ -77,7 +78,7 @@ func main() { broken := oldStr != "" && strings.Count(oldStr, "%") != strings.Count(newStr, "%") broken = broken || strings.Contains(oldStr, "\n") || strings.Contains(oldStr, "\n") if broken { - brokenWarned[secOld.Name()+"."+keyEnUs.Name()] = true + brokenWarned.Add(secOld.Name() + "." + keyEnUs.Name()) fmt.Println("----") fmt.Printf("WARNING: skip broken locale: %s , [%s] %s\n", path, secEnUS.Name(), keyEnUs.Name()) fmt.Printf("\told: %s\n", strings.ReplaceAll(oldStr, "\n", "\\n")) @@ -103,7 +104,7 @@ func main() { broken = broken || strings.HasPrefix(str, "`\"") broken = broken || strings.Count(str, `"`)%2 == 1 broken = broken || strings.Count(str, "`")%2 == 1 - if broken && !brokenWarned[sec.Name()+"."+key.Name()] { + if broken && !brokenWarned.Contains(sec.Name()+"."+key.Name()) { fmt.Printf("WARNING: found broken locale: %s , [%s] %s\n", path, sec.Name(), key.Name()) fmt.Printf("\tstr: %s\n", strings.ReplaceAll(str, "\n", "\\n")) fmt.Println("----") diff --git a/build/generate-go-licenses.go b/build/generate-go-licenses.go index c3b40c226f..84ba39025c 100644 --- a/build/generate-go-licenses.go +++ b/build/generate-go-licenses.go @@ -15,6 +15,8 @@ import ( "regexp" "sort" "strings" + + "code.gitea.io/gitea/modules/container" ) // regexp is based on go-license, excluding README and NOTICE @@ -55,20 +57,14 @@ func main() { // yml // // It could be removed once we have a better regex. - excludedExt := map[string]bool{ - ".gitignore": true, - ".go": true, - ".mod": true, - ".sum": true, - ".toml": true, - ".yml": true, - } + excludedExt := container.SetOf(".gitignore", ".go", ".mod", ".sum", ".toml", ".yml") + var paths []string err := filepath.WalkDir(base, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } - if entry.IsDir() || !licenseRe.MatchString(entry.Name()) || excludedExt[filepath.Ext(entry.Name())] { + if entry.IsDir() || !licenseRe.MatchString(entry.Name()) || excludedExt.Contains(filepath.Ext(entry.Name())) { return nil } paths = append(paths, path) diff --git a/cmd/actions.go b/cmd/actions.go index 052afb9ebc..f582c16c81 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 a4f48b0513..6c9480e76e 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 0000000000..ec92e342d4 --- /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 cfa1a23235..e3c81809f8 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 210a6463c3..7791f3a9cc 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 0000000000..c151c0af27 --- /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 0000000000..58a6e2ac22 --- /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 0000000000..ab769f6d0c --- /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 eebbfb3b67..824d66d112 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 52ce46c353..a257ce21c8 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" ) @@ -115,7 +115,7 @@ func runCreateUser(c *cli.Context) error { // If this is the first user being created. // Take it as the admin and don't force a password update. - if n := user_model.CountUsers(nil); n == 0 { + if n := user_model.CountUsers(ctx, nil); n == 0 { changePassword = false } @@ -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,11 +142,11 @@ func runCreateUser(c *cli.Context) error { } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), IsRestricted: restricted, } - if err := user_model.CreateUser(u, overwriteDefault); err != nil { + if err := user_model.CreateUser(ctx, u, overwriteDefault); err != nil { return fmt.Errorf("CreateUser: %w", err) } @@ -156,7 +156,7 @@ func runCreateUser(c *cli.Context) error { UID: u.ID, } - if err := auth_model.NewAccessToken(t); err != nil { + if err := auth_model.NewAccessToken(ctx, t); err != nil { return err } diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go index 0febb91661..6e78939680 100644 --- a/cmd/admin_user_generate_access_token.go +++ b/cmd/admin_user_generate_access_token.go @@ -63,7 +63,7 @@ func runGenerateAccessToken(c *cli.Context) error { UID: user.ID, } - exist, err := auth_model.AccessTokenByNameExists(t) + exist, err := auth_model.AccessTokenByNameExists(ctx, t) if err != nil { return err } @@ -79,7 +79,7 @@ func runGenerateAccessToken(c *cli.Context) error { t.Scope = accessTokenScope // create the token - if err := auth_model.NewAccessToken(t); err != nil { + if err := auth_model.NewAccessToken(ctx, t); err != nil { return err } diff --git a/cmd/admin_user_list.go b/cmd/admin_user_list.go index 9db9b5e56f..4c2b26d1df 100644 --- a/cmd/admin_user_list.go +++ b/cmd/admin_user_list.go @@ -33,7 +33,7 @@ func runListUsers(c *cli.Context) error { return err } - users, err := user_model.GetAllUsers() + users, err := user_model.GetAllUsers(ctx) if err != nil { return err } @@ -48,7 +48,7 @@ func runListUsers(c *cli.Context) error { } } } else { - twofa := user_model.UserList(users).GetTwoFaStatus() + twofa := user_model.UserList(users).GetTwoFaStatus(ctx) fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n") for _, u := range users { fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID]) diff --git a/cmd/doctor.go b/cmd/doctor.go index d040a3af1c..e433f4adc5 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 2385f23e52..48c835ad0e 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 0000000000..3e1ff299c5 --- /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 9b5259b86f..69ecdcec12 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -128,7 +128,7 @@ It can be used for backup and capture Gitea server image to send to maintainer`, &cli.StringFlag{ Name: "database", Aliases: []string{"d"}, - Usage: "Specify the database SQL syntax", + Usage: "Specify the database SQL syntax: sqlite3, mysql, mssql, postgres", }, &cli.BoolFlag{ Name: "skip-repository", @@ -452,7 +452,7 @@ func addRecursiveExclude(w archiver.Writer, insidePath, absPath string, excludeA return err } for _, file := range files { - currentAbsPath := path.Join(absPath, file.Name()) + currentAbsPath := filepath.Join(absPath, file.Name()) currentInsidePath := path.Join(insidePath, file.Name()) if file.IsDir() { if !util.SliceContainsString(excludeAbsPath, currentAbsPath) { diff --git a/cmd/generate.go b/cmd/generate.go index 5922617217..90b32ecaf0 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 f38fd8831b..6a3358853d 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++ @@ -669,7 +671,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 diff --git a/cmd/keys.go b/cmd/keys.go index b846782529..7fdbe16119 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 646330e85a..0c5f2c8c8d 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 feda41e68b..02dd660e9e 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 0e32dce564..a916c61f85 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 7f94e11ea9..aa49445a89 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 }) @@ -185,7 +191,7 @@ func runMigrateStorage(ctx *cli.Context) error { case string(setting.LocalStorageType): p := ctx.String("path") if p == "" { - log.Fatal("Path must be given when storage is loal") + log.Fatal("Path must be given when storage is local") return nil } dstStorage, err = storage.NewLocalStorage( diff --git a/cmd/migrate_storage_test.go b/cmd/migrate_storage_test.go index 644e0dc18b..5d8c867993 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 26fc91a3b7..90190a19db 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/contrib/backport/backport.go b/contrib/backport/backport.go index 5cd0fe0f6e..820c0702b7 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 7758045fd3..a7d7a6d293 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 06c0354efa..31797cc800 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 c097fb0d17..c091722a74 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 0000000000..35a38d768c --- /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 96a0a3ede9..1584b10301 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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -759,6 +772,8 @@ LEVEL = Info ;; ;; More detail: https://github.com/gogits/gogs/issues/165 ;ENABLE_REVERSE_PROXY_AUTHENTICATION = false +; Enable this to allow reverse proxy authentication for API requests, the reverse proxy is responsible for ensuring that no CSRF is possible. +;ENABLE_REVERSE_PROXY_AUTHENTICATION_API = false ;ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = false ;ENABLE_REVERSE_PROXY_EMAIL = false ;ENABLE_REVERSE_PROXY_FULL_NAME = false @@ -941,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 = ;; @@ -948,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. @@ -1012,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 @@ -1033,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 @@ -1056,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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1149,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 ;; @@ -1203,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. @@ -1231,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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1418,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 @@ -1448,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 = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1512,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. @@ -1686,9 +1732,6 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; -;; if the cache enabled -;ENABLED = true -;; ;; Either "memory", "redis", "memcache", or "twoqueue". default is "memory" ;ADAPTER = memory ;; @@ -1713,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 @@ -1744,8 +1785,8 @@ LEVEL = Info ;; Session cookie name ;COOKIE_NAME = i_like_gitea ;; -;; If you use session in https only, default is false -;COOKIE_SECURE = false +;; If you use session in https only: true or false. If not set, it defaults to `true` if the ROOT_URL is an HTTPS URL. +;COOKIE_SECURE = ;; ;; Session GC time interval in seconds, default is 86400 (1 day) ;GC_INTERVAL_TIME = 86400 @@ -1810,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 @@ -1824,8 +1865,9 @@ LEVEL = Info ;; Currently, only `minio` is supported. ;SERVE_DIRECT = false ;; -;; Path for attachments. Defaults to `data/attachments` only available when STORAGE_TYPE is `local` -;PATH = data/attachments +;; Path for attachments. Defaults to `attachments`. Only available when STORAGE_TYPE is `local` +;; Relative paths will be resolved to `${AppDataPath}/${attachment.PATH}` +;PATH = attachments ;; ;; Minio endpoint to connect only available when STORAGE_TYPE is `minio` ;MINIO_ENDPOINT = localhost:9000 @@ -2559,10 +2601,20 @@ LEVEL = Info ; [actions] ;; Enable/Disable actions capabilities -;ENABLED = false +;ENABLED = true ;; ;; 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. 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 0acfec4dbe..d9dbb3ebe0 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 ed11881b77..0000000000 --- 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 455d6a363f..0000000000 --- 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 e69de29bb2..0000000000 diff --git a/docs/content/administration/adding-legal-pages.en-us.md b/docs/content/administration/adding-legal-pages.en-us.md index c6f68edcd0..1ff0c0132d 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 5d582e871d..3e18c6e6b0 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 251bd53afe..451ef5c944 100644 --- a/docs/content/administration/backup-and-restore.en-us.md +++ b/docs/content/administration/backup-and-restore.en-us.md @@ -43,10 +43,10 @@ directory. There should be some output similar to the following: Inside the `gitea-dump-1482906742.zip` file, will be the following: - `app.ini` - Optional copy of configuration file if originally stored outside the default `custom/` directory -- `custom` - All config or customization files in `custom/`. -- `data` - Data directory (APP_DATA_PATH), except sessions if you are using file session. This directory includes `attachments`, `avatars`, `lfs`, `indexers`, SQLite file if you are using SQLite. +- `custom/` - All config or customization files in `custom/`. +- `data/` - Data directory (APP_DATA_PATH), except sessions if you are using file session. This directory includes `attachments`, `avatars`, `lfs`, `indexers`, SQLite file if you are using SQLite. +- `repos/` - Complete copy of the repository directory. - `gitea-db.sql` - SQL dump of database -- `gitea-repo.zip` - Complete copy of the repository directory. - `log/` - Various logs. They are not needed for a recovery or migration. Intermediate backup files are created in a temporary directory specified either with the @@ -89,10 +89,10 @@ Example: ```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/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. @@ -126,7 +128,7 @@ cd gitea-dump-1610949662 # restore the gitea data mv data/* /data/gitea # restore the repositories itself -mv repos/* /data/git/repositories/ +mv repos/* /data/git/gitea-repositories/ # adjust file permissions chown -R git:git /data # Regenerate Git Hooks @@ -150,7 +152,7 @@ mv data/conf/app.ini /etc/gitea/app.ini # restore the gitea data mv data/* /var/lib/gitea # restore the repositories itself -mv repos/* /var/lib/gitea/git/repositories +mv repos/* /var/lib/gitea/git/gitea-repositories # adjust file permissions chown -R git:git /etc/gitea/app.ini /var/lib/gitea # Regenerate Git Hooks diff --git a/docs/content/administration/backup-and-restore.zh-cn.md b/docs/content/administration/backup-and-restore.zh-cn.md index 98d378d5dc..db7eba84f7 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 4966ccdc50..0000000000 --- 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,11 +637,12 @@ 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. -- `ENABLE_REVERSE_PROXY_AUTHENTICATION`: **false**: Enable this to allow reverse proxy authentication. +- `ENABLE_REVERSE_PROXY_AUTHENTICATION`: **false**: Enable this to allow reverse proxy authentication for web requests +- `ENABLE_REVERSE_PROXY_AUTHENTICATION_API`: **false**: Enable this to allow reverse proxy authentication for API requests, the reverse proxy is responsible for ensuring that no CSRF is possible. - `ENABLE_REVERSE_PROXY_AUTO_REGISTRATION`: **false**: Enable this to allow auto-registration for reverse authentication. - `ENABLE_REVERSE_PROXY_EMAIL`: **false**: Enable this to allow to auto-registration with a @@ -756,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. @@ -768,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. @@ -776,7 +795,7 @@ and - `PROVIDER`: **memory**: Session engine provider \[memory, file, redis, redis-cluster, db, mysql, couchbase, memcache, postgres\]. Setting `db` will reuse the configuration in `[database]` - `PROVIDER_CONFIG`: **data/sessions**: For file, the root path; for db, empty (database config will be used); for others, the connection string. Relative paths will be made absolute against _`AppWorkPath`_. -- `COOKIE_SECURE`: **false**: Enable this to force using HTTPS for all session access. +- `COOKIE_SECURE`:**_empty_**: `true` or `false`. Enable this to force using HTTPS for all session access. If not set, it defaults to `true` if the ROOT_URL is an HTTPS URL. - `COOKIE_NAME`: **i\_like\_gitea**: The name of the cookie used for the session ID. - `GC_INTERVAL_TIME`: **86400**: GC interval in seconds. - `SESSION_LIFE_TIME`: **86400**: Session life time in seconds, default is 86400 (1 day) @@ -817,12 +836,12 @@ 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. -- `PATH`: **data/attachments**: Path to store attachments only available when STORAGE_TYPE is `local` +- `PATH`: **attachments**: Path to store attachments only available when STORAGE_TYPE is `local`, relative paths will be resolved to `${AppDataPath}/${attachment.PATH}`. - `MINIO_ENDPOINT`: **localhost:9000**: Minio endpoint to connect only available when STORAGE_TYPE is `minio` - `MINIO_ACCESS_KEY_ID`: Minio accessKeyID to connect only available when STORAGE_TYPE is `minio` - `MINIO_SECRET_ACCESS_KEY`: Minio secretAccessKey to connect only available when STORAGE_TYPE is `minio` @@ -955,6 +974,12 @@ Default templates for project boards: - `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts. - `UPDATE_EXISTING`: **true**: Create new users, update existing user data and disable users that are not in external source anymore (default) or only create new users if UPDATE_EXISTING is set to false. +## Cron - Cleanup Expired Actions Assets (`cron.cleanup_actions`) + +- `ENABLED`: **true**: Enable cleanup expired actions assets job. +- `RUN_AT_START`: **true**: Run job at start time (if ENABLED). +- `SCHEDULE`: **@midnight** : Cron syntax for the job. + ### Extended cron tasks (not enabled by default) #### Cron - Garbage collect all repositories (`cron.git_gc_repos`) @@ -1091,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 @@ -1100,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`) @@ -1270,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] @@ -1377,23 +1402,28 @@ PROXY_HOSTS = *.github.com ## Actions (`actions`) -- `ENABLED`: **false**: Enable/Disable actions capabilities +- `ENABLED`: **true**: Enable/Disable actions capabilities - `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**: 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`) diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md index 39121908c2..41c8844ae5 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`_**:模板和静态文件路径的上一级。 @@ -436,7 +434,7 @@ menu: - `SQLITE_JOURNAL_MODE`:**""**:更改 SQlite3 的日志模式。可以用于在高负载导致写入拥塞时启用 [WAL 模式](https://www.sqlite.org/wal.html)。有关可能的值,请参阅 [SQlite3 文档](https://www.sqlite.org/pragma.html#pragma_journal_mode)。默认为数据库文件的默认值,通常为 DELETE。 - `ITERATE_BUFFER_SIZE`:**50**:用于迭代的内部缓冲区大小。 - `PATH`:**data/gitea.db**:仅适用于 SQLite3 的数据库文件路径。 -- `LOG_SQL`:**true**:记录已执行的 SQL。 +- `LOG_SQL`:**false**:记录已执行的 SQL。 - `DB_RETRIES`:**10**:允许多少次 ORM 初始化 / DB 连接尝试。 - `DB_RETRY_BACKOFF`:**3s**:如果发生故障,等待另一个 ORM 初始化 / DB 连接尝试的 time.Duration。 - `MAX_OPEN_CONNS`:**0**:数据库最大打开连接数 - 默认为 0,表示没有限制。 @@ -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**:仅在存储库的提交计数大于时启用缓存。 @@ -742,7 +741,7 @@ Gitea 创建以下非唯一队列: - `PROVIDER`: **memory**:会话存储引擎 \[memory, file, redis, redis-cluster, db, mysql, couchbase, memcache, postgres\]。设置为 `db` 将会重用 `[database]` 的配置信息。 - `PROVIDER_CONFIG`: **data/sessions**:对于文件,为根路径;对于 db,为空(将使用数据库配置);对于其他引擎,为连接字符串。相对路径将根据 _`AppWorkPath`_ 绝对化。 -- `COOKIE_SECURE`: **false**:启用此选项以强制在所有会话访问中使用 HTTPS。 +- `COOKIE_SECURE`: **_empty_**:`true` 或 `false`。启用此选项以强制在所有会话访问中使用 HTTPS。如果没有设置,当 ROOT_URL 是 https 链接的时候默认设置为 true。 - `COOKIE_NAME`: **i\_like\_gitea**:用于会话 ID 的 cookie 名称。 - `GC_INTERVAL_TIME`: **86400**:GC 间隔时间,以秒为单位。 - `SESSION_LIFE_TIME`: **86400**:会话生命周期,以秒为单位,默认为 86400(1 天)。 @@ -783,12 +782,12 @@ 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 不会执行任何操作。 -- `PATH`: **data/attachments**: 存储附件的路径,仅当 STORAGE_TYPE 为 `local` 时可用。 +- `PATH`: **attachments**: 存储附件的路径,仅当 STORAGE_TYPE 为 `local` 时可用。如果是相对路径,将会被解析为 `${AppDataPath}/${attachment.PATH}`. - `MINIO_ENDPOINT`: **localhost:9000**: Minio 端点以连接,仅当 STORAGE_TYPE 为 `minio` 时可用。 - `MINIO_ACCESS_KEY_ID`: Minio accessKeyID 以连接,仅当 STORAGE_TYPE 为 `minio` 时可用。 - `MINIO_SECRET_ACCESS_KEY`: Minio secretAccessKey 以连接,仅当 STORAGE_TYPE 为 `minio` 时可用。 @@ -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`) @@ -1331,23 +1331,23 @@ PROXY_HOSTS = *.github.com ## Actions (`actions`) -- `ENABLED`: **false**:启用/禁用操作功能 +- `ENABLED`: **true**:启用/禁用操作功能 - `DEFAULT_ACTIONS_URL`: **github**:获取操作插件的默认平台,`github`表示`https://github.com`,`self`表示当前的 Gitea 实例。 - `STORAGE_TYPE`: **local**:用于操作日志的存储类型,`local`表示本地磁盘,`minio`表示与S3兼容的对象存储服务,默认为`local`,或者使用定义为`[storage.xxx]`的其他名称。 - `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`) diff --git a/docs/content/administration/customizing-gitea.en-us.md b/docs/content/administration/customizing-gitea.en-us.md index 4c2d7ed0c4..7efddb2824 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 2babf03da7..f41533c69c 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 645a7a3f43..f9621e6075 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 0a7ac3378f..2e670be85b 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 f910cf060e..2c6fcbe681 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 25e120becd..d5be9e03c2 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.en-us.md b/docs/content/administration/external-renderers.en-us.md index f903a7e760..1e41b80145 100644 --- a/docs/content/administration/external-renderers.en-us.md +++ b/docs/content/administration/external-renderers.en-us.md @@ -24,7 +24,7 @@ it is just a matter of: - add some configuration to your `app.ini` file - restart your Gitea instance -This supports rendering of whole files. If you want to render code blocks in markdown you would need to do something with javascript. See some examples on the [Customizing Gitea](../customizing-gitea) page. +This supports rendering of whole files. If you want to render code blocks in markdown you would need to do something with javascript. See some examples on the [Customizing Gitea](administration/customizing-gitea.md) page. ## Installing external binaries diff --git a/docs/content/administration/external-renderers.zh-cn.md b/docs/content/administration/external-renderers.zh-cn.md index 0b53b45277..fdf7315d7b 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 6441663c85..981a29bd85 100644 --- a/docs/content/administration/https-support.en-us.md +++ b/docs/content/administration/https-support.en-us.md @@ -35,8 +35,8 @@ 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. -To learn more about the config values, please checkout the [Config Cheat Sheet](../config-cheat-sheet#server-server). +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. @@ -85,11 +85,11 @@ ACME_DIRECTORY=https ACME_EMAIL=email@example.com ``` -To learn more about the config values, please checkout the [Config Cheat Sheet](../config-cheat-sheet#server-server). +To learn more about the config values, please checkout the [Config Cheat Sheet](administration/config-cheat-sheet.md#server-server). ## Using a reverse proxy -Setup up your reverse proxy as shown in the [reverse proxy guide](../reverse-proxies). +Setup up your reverse proxy as shown in the [reverse proxy guide](administration/reverse-proxies.md). After that, enable HTTPS by following one of these guides: diff --git a/docs/content/administration/https-support.zh-cn.md b/docs/content/administration/https-support.zh-cn.md index 124242f744..8beb06e80f 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”环境变量。它也可以是绝对路径。 @@ -82,11 +82,11 @@ ACME_DIRECTORY=https ACME_EMAIL=email@example.com ``` -要了解关于配置, 请访问 [配置备忘单](../config-cheat-sheet#server-server)获取更多信息 +要了解关于配置, 请访问 [配置备忘单](administration/config-cheat-sheet.md#server-server)获取更多信息 ## 使用反向代理服务器 -按照 [reverse proxy guide](../reverse-proxies) 的规则设置你的反向代理服务器 +按照 [reverse proxy guide](administration/reverse-proxies.md) 的规则设置你的反向代理服务器 然后,按照下面的向导启用 HTTPS: diff --git a/docs/content/administration/mail-templates.en-us.md b/docs/content/administration/mail-templates.en-us.md index a129453e1a..8e4e416e8d 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 87585b2b69..68e19ef2be 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 db89cf9e05..4e43bdf2f4 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 e2c6f7db72..9ce3077a96 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 0e26bcd452..4182100185 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 16598dd43b..5ed1e264ca 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 b633934850..595dec0e68 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 1d36330524..453b9befcc 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 0000000000..6aad3d3a64 --- /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 35e19439f0..9d3b342b3d 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 54ded7eb87..d24c0be537 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 576ec72a78..feddf73ca4 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 f719dadc5d..f98cef7bdc 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 2ab660c500..2a728a9cf7 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 6e26a53f3b..fcd56827f5 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 6173f0648c..a2135affc1 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 0d81ca2f4b..1d97fbeb54 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 6816984e69..48ad67ec63 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 e5fa8275f8..aec2d6ac08 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 6001d57767..720f320826 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 6fb58b2677..87b526311e 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 e347e06005..0e498725bc 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 993df7ecf2..5298ea1421 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 daf1fc001d..8d1bc1d7ac 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 4d760cec65..d3c0533c2f 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 fdd8fa6a2f..b790d6e4e2 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 7c72b85494..ce0ad4d815 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 96cdfb2842..a385b9e5fd 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 e1989c6b39..344671d8a8 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 c2f18ab371..26903da2b0 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 be9ec7e6e9..0d0a7c0b34 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 396bf74ccf..ea2545975e 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 ee48f48a84..3b58ec1eaa 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 c3ebabe79d..781ae92d22 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 7d3559638c..3293cc80ed 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 a89f1250b7..e8c9f6d21b 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 f398ef82b8..20a09048d5 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 1630aa2224..5b8a7924bb 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 3d43f26bc9..55fd2f49da 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 f3a9f6a15d..8840d55ce1 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 89ce3a750c..63ff6d2b21 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 e38a8f4103..b76500b131 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 88ff9cf487..b9711c5509 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 7d4cd8566d..172f13a433 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 84e71ca4d8..824e4764ef 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 a56612a7eb..ec2e25a968 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 e9d04c151a..4a575153af 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 958bd3ab98..4dac4b5049 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 817158aa07..fbc3865094 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 6dc288a16e..c2fa88b929 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 43e87de195..423e5fd29d 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 f1140b8efd..186f6fbefc 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 f0963b818d..a4c3091638 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 7ff6f5b3ea..38b6a76122 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 2477aa7279..20d4152643 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 ce9e16cb42..2ba5e12569 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 a95ca37e16..8dadc44eee 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 33c920829d..a18c3b6ef6 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 9e150c49c3..e81d4e9e53 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 85cbc27392..c03a0e582e 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 701ceefd5f..30a3890b56 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 a6344001a1..a88b8071c2 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 e41255b40b..6c8528180e 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 6be15c7bcf..2a2728db04 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 6340385879..916d808d9f 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 2e6056bf41..c8eeeb16ec 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 caaf0d5ec3..d213328a45 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 ce95645204..af5fa66827 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 b2a143c5c6..c23f45407f 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 41b6e7cfae..753b9b5799 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 1ba32dee72..94c8f1581e 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 cca24cd13f..80b122b356 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 f3665638a6..39ebd0d264 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 03b5de464f..cbc9749286 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 cfda70fee2..250d10cc1b 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 643829c9e9..4ddaa137de 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 ebbed57707..4e7e1e7c4d 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 7ad8d402ac..bf4de4a59c 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 2bb49e4778..4a6183688f 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 2f0ca59752..cf247e215c 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 c351640dea..84d2a88feb 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 cc30842ce9..82d0bcbcfe 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 9ac76132be..276d162129 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 47d59486e5..6949409bdf 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 68969b6865..1c0d7296aa 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 86bcb54838..d366a10755 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 96bec22a26..f07999a660 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 30bc1e95df..4714b0fdd4 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 08c17812d7..d97598d2aa 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 8c705dbd98..01fd6641e2 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 960920d696..515e63afae 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 d9c61d9db5..17db30b0e0 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 62dcc189fe..fe03e3ded7 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 f5ae4e389d..8058419830 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 323c8f3df6..289ac5905f 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 8f4da5d805..152e6eb324 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 2cfdf66406..3b493863cd 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 5f63e08dea..c0b3648c63 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 7ffa53cdca..e8ccfff386 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 c64a5a1a22..65b0eb1b13 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 3179473eeb..f2d620dd47 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 4fea50bac6..9cd3127afc 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 3720b5d9b3..091a59163e 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 4e497682e5..6d8989895b 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 0000000000..c82d5b0fdc --- /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 4679ea202b..2984bef227 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 7c9bbf4c37..0854866216 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 a453bf11e9..6d0286e22e 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 4decd91300..926f7fe26a 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 824a4e06ab..b594acd9cc 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 5091a4a0a9..1ae02e68ef 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 276e02f925..d1349c2d6c 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 69a1989476..fe099c52d2 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 843560a9de..48587a1ab6 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 1688aab55d..3c3a6aa48a 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 c598c4d4c4..d63dd74c4d 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 6dc8d5a56b..ba06c3e712 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 927784d2e6..a8cd80f3e8 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 944b522757..5f0a74206a 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 45f4e86fb6..492e7d5433 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 38baefd9e9..66430e3082 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 6fdf5c5735..a58df3e53d 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 f16ba39e0e..800e6ba952 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 ca7a4adf6b..0d1bac328a 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 ddc2e3fd67..8a6142b229 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 86670ef6ce..03735b0e98 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 ba947cc988..8eab90f8c6 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 bc712b5ba1..21b8cbf516 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 2f02503054..3adddf0aa1 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 976d8d99d3..faf92c5431 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 0000000000..c10010622b --- /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 f93cc0159b..63cd16e647 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 67f683604b..877850ad24 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 6fa5050d6b..0db84ac379 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 22e6ad1d44..957a95fcea 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 2b7d4bdf8d..c139cf8362 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 7fd4e9d56d..5657e930c1 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 6f5e36af10..be6eee1b8a 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 866461c453..4a6ba3049a 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 fdd05567b4..c7116adf01 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 6e5b5fc07f..6c2ac50ac3 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 3c61c10257..6bf455942d 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 4890a68d8c..bfe39b34e6 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 6aa3d4ce4e..1729961477 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 47b18f87e9..628f9fe6da 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 43ebe4569c..74d9af987a 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 e9965677d2..2277666fe0 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 a90f1d16ad..60ca5b57e3 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 46f224661f..dd36a51fb7 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 33cc247819..9239188d42 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 fa108db0a0..393faf95bd 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 3c74070d2b..9920504192 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 9caceb6157..597e7949a5 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 0665a16894..1f23ef46da 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 9f178cfded..3980b80b52 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 72526f113c..fb835dc4aa 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 0ebd98fccb..2586237e1f 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 1cdc4a8ccf..978874be5a 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 815ddcd1c1..8f84421c9e 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 6ba0c672e6..0042389648 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 927d780883..ed3acea8bf 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 47b70c1663..a3ce77a876 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 42cc08fa80..3d65320ac6 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 7e52e4ad6e..2edd1ef91b 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 ab4b4c5970..de6616b0b7 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 51be962aac..1d0aa0c2b4 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 8ec8582479..d02ddd3e0c 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 9f60583d99..fb0752dcf3 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 48eebc2e0e..cd72facc37 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 2fd4c95f3e..3cabd7851d 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 503ed5cdaf..7eb86e5151 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 1123748647..2f71fcc933 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 a8a6a2555e..33e98c1de3 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 faa581329f..d744abf8d8 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 f04c044590..8b57e47885 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 10c0b1aa8a..6705b71383 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 8df6eec3bb..841798e644 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 f3c58d47a5..c438ecf25e 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 4479d3aac0..6d7750b9d2 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 e6b60a12f7..9269974e49 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 dabed4edce..004070815a 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 32640eca97..1976bd89db 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 81c3ed60d4..7f91acc2e7 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 3e3dae06ca..b737b56f77 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 21c263e792..0f71230f06 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 861764a663..8621f14776 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 546a7cd252..02d92c9b13 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 6a6a036410..750b742e6c 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 0e5bf3b4d6..9381053c06 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 f2a69adb7d..178b55092b 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 6066757558..75b414e123 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 d60c068987..38bd456445 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 69da41891d..19667b613c 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 0acd01b01b..d9c67fcf84 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 a51e223034..244544d6c1 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 871d1ae702..8a9f954546 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 3fb8f97391..3f2a183df9 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 d4b5cb62bf..ef3e60bc3a 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 00a24a4c9f..2dc1df7344 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 f0726dd988..109515cc52 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 cebcebce6c..e079b24a5e 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 db465e41d5..2bb114f8a5 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 ac897b84b4..e7117cd1ca 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 69b64feeba..2fef71871a 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 d3eb92b3b5..39e7e4e7e5 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 22cf468468..0a88de18aa 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 beef876a97..0799b07311 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 ae408868b8..8cb567153b 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 bad01efc9b..22dd4d1626 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 aab1e40c4d..61b222508c 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 8cbfcf3ee4..d9ac07654c 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 326c8b8c3f..de38702a44 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 bca3a24757..98fcafb772 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 1b3ee09ce1..212c2b82aa 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 41c638fced..f0f1f7387c 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 14e9d75d4d..9143c70a46 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 a04727ce0b..4c9517269d 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 aef5059807..4715c29f03 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 0c18220b41..2d12f02377 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 4cd88d58a1..adf7ed4a7a 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 e24d9cc817..49ac5af31a 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 34370c2060..795fde10d1 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 39a3650dfd..dca6572781 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 e496b584d3..4caf972b67 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 d8f4cee27d..71c55630a8 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 227b946e35..1fd3743532 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 a61cd569b6..a6b15c1ff2 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 0ca269236b..bc9104a871 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 8a7013a7a0..31d4cc06b7 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 52d86d4b77..9fb23c758d 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 b164f2ea76..2450ffeabe 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 b21e867525..6d6a3f5353 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 2647b0bf47..c3b02f4831 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 dc27b12ae2..cd5ee2028d 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 a4fbeabeaa..a81a32131f 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 4f235bae2e..45837c2cd3 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 124511dae2..70e550e5d7 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 72e245fc34..67099d8170 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 59fbeaff62..c79e3be28c 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 08bd8e5a70..a45bee68ca 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 382a1f0a78..c6def5b514 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 743aeec31d..4f02f6f127 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 f473dcb2b9..07bd7626e2 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 12da1960e0..45b7acf4f9 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 c237a5f1f4..ace4a3c72d 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 615c52b20d..e0cf565136 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 e3a5f805d1..13395eb500 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 9cd752698d..4596215238 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 d9aa600d33..6b1303340a 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 b4f9f426f4..3697948a68 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 040ddf9ed9..8074a22cf2 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 1cf1e08689..98fe6a8721 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 5c3a88bca6..5286c0440c 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 3d80f856ba..fd4e9be060 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 81d0df456e..2e1cdcbde5 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 13df0052fb..99fc924261 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 cc85d28211..2057bbc8f9 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 b4c2fe18df..28a2b85847 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 a87b9c189c..b0fff66035 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 be209575db..865238e563 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 7b307bdda2..6885cd277f 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 42816121a6..41e5c800f1 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 f44aa4a614..308c118783 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 b703ae8b56..ac1c95f993 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 d3e854314e..01a5cc8d3f 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 9790505438..fe8d37792e 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 a76bf5180f..940a3abde1 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 619b0960f7..11c9055fd0 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 fe05e58ed8..76fe377cb8 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 b35567d75f..1ae84a7fe7 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 3b8a7d276f..fafd3d839e 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 08e0ae6e60..efe96cd5a8 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 24deb106db..06d32b3c78 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 4d04ca8061..60b3ba5c58 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 683c6c4e2d..a86dbbee8d 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 3d5c976fef..174ae0c2c0 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 42e42ab5e6..2001b8c0e8 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 03cdceb1f2..d85c7a35c2 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 b63aba3421..eeec8dc396 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 d75258995b..1a9c4a0d3e 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 1abeab2d1c..07756fa438 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 146e48f379..30917e16b5 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 b86888e687..e1a979df1c 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 35c4f2e817..0de0f6b4fe 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 a568cc8c04..b1067aefc3 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 c1961c3323..4debe301d6 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 ccac95cd59..17e79115ff 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 4744caec0a..e6349af701 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 2b6fc0abed..cf97b03044 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 1a22693ea0..f64457ec51 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 ed38245f05..1afc4ba99b 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 8b051eb3fd..851c44f2ea 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 7a3af6dee2..c906f0eb6c 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 bbeb817b82..f738398428 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 d0c0a5f712..b52a439af7 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 fd1dce1fe8..e8034484fc 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 66343551a9..fde4d16bad 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 7b39a67e6e..48a009760e 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 f4b386b175..1982cc84c4 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 0f1328e2e0..62c37e70d4 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 0b2088ed2f..3ed9d8ff68 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 53ac646414..7dd709241e 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 ff4a0dd56a..e383765b0c 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 0f77b14734..a585e8ba90 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 b0739c6703..efeb0ef383 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 79234174e9..f471217b63 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 9652a18ff0..f2c41413b1 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 c4bbddfab0..5833f2316a 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 16e3f04222..c61e952511 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 a6de1a30d6..57fc6a0eab 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 b37de6aebe..7f5428055b 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 95d92d87a6..600a42bbea 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 afb2e08209..cce7537fb8 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 eafea6df78..c0a6307884 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 26cce460d5..94dc6b9281 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 de54c0928e..2d78857db2 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 f711e01eb1..a693cb6d95 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 88540b5766..1713ae7df1 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 d03925e0df..9d682db55f 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 549a8cb2b0..bb768d5cb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,12 @@ description = "" authors = [] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.10" [tool.poetry.group.dev.dependencies] -djlint = "1.32.1" +djlint = "1.34.1" +yamllint = "1.35.1" [tool.djlint] profile="golang" -ignore="H005,H006,H008,H013,H016,H020,H021,H030,H031" +ignore="H005,H006,H013,H016,H020,H021,H030,H031" diff --git a/routers/api/actions/artifact.pb.go b/routers/api/actions/artifact.pb.go new file mode 100644 index 0000000000..590eda9fb9 --- /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 0000000000..c68e5d030d --- /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 946ea11e75..d530e9cee5 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 { @@ -170,8 +193,9 @@ func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix stri } type getUploadArtifactRequest struct { - Type string - Name string + Type string + Name string + RetentionDays int64 } type getUploadArtifactResponse struct { @@ -192,10 +216,16 @@ func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { return } + // set retention days + retentionQuery := "" + if req.RetentionDays > 0 { + retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays) + } + // use md5(artifact_name) to create upload url artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name))) resp := getUploadArtifactResponse{ - FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"), + FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery), } log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL) ctx.JSON(http.StatusOK, resp) @@ -219,8 +249,21 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { return } + // get artifact retention days + expiredDays := setting.Actions.ArtifactRetentionDays + if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" { + expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64) + if err != nil { + log.Error("Error parse retention days: %v", err) + ctx.Error(http.StatusBadRequest, "Error parse retention days") + return + } + } + log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d", + artifactName, artifactPath, fileRealTotalSize, expiredDays) + // create or get artifact with name and path - artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath) + artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays) if err != nil { log.Error("Error create or get artifact: %v", err) ctx.Error(http.StatusInternalServerError, "Error create or get artifact") @@ -237,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") @@ -247,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{ @@ -294,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()) @@ -356,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()) @@ -376,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", @@ -399,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 30d31b4d75..3a81724b3a 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -5,18 +5,70 @@ 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 saveUploadChunkBase(st storage.ObjectStorage, ctx *ArtifactContext, + artifact *actions.ActionArtifact, + contentSize, runID, start, end, length int64, checkMd5 bool, +) (int64, error) { + // build chunk store path + 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) + } + 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, checkErr + } + log.Info("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d", + storagePath, contentSize, artifact.ID, start, end) + // return chunk total size + return length, nil +} + func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, artifact *actions.ActionArtifact, contentSize, runID int64, @@ -25,38 +77,22 @@ func saveUploadChunk(st storage.ObjectStorage, ctx *ArtifactContext, 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) } - // 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) - // 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 { - if err := st.Delete(storagePath); err != nil { - log.Error("Error deleting chunk: %s, %v", storagePath, err) - } - return -1, fmt.Errorf("md5 not match") - } - log.Info("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, end: %d", - storagePath, contentSize, artifact.ID, start, end) - // return chunk total size - return length, nil + 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,10 +226,25 @@ 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 = actions.ArtifactStatusUploadConfirmed + artifact.Status = int64(actions.ArtifactStatusUploadConfirmed) if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { return fmt.Errorf("update artifact error: %v", err) } diff --git a/routers/api/actions/artifacts_utils.go b/routers/api/actions/artifacts_utils.go index 4c93934862..aaf89ef40e 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 0000000000..8300989c75 --- /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 55219fe12b..828350407a 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 f39e94a1f3..098b003ea2 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 ddc754dbc7..c2f4ade174 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 6de5964cb7..1d07be3aec 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 @@ -202,8 +200,14 @@ func (s *Service) UpdateTask( if err := task.LoadJob(ctx); err != nil { return nil, status.Errorf(codes.Internal, "load job: %v", err) } + if err := task.Job.LoadRun(ctx); err != nil { + return nil, status.Errorf(codes.Internal, "load run: %v", err) + } - actions_service.CreateCommitStatus(ctx, task.Job) + // don't create commit status for cron job + if task.Job.Run.ScheduleID == 0 { + actions_service.CreateCommitStatus(ctx, task.Job) + } if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { if err := actions_service.EmitJobsIfReady(task.Job.RunID); err != nil { diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go index e95df7a00f..ff6ec5bd54 100644 --- a/routers/api/actions/runner/utils.go +++ b/routers/api/actions/runner/utils.go @@ -8,12 +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" @@ -30,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 { @@ -53,68 +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{} - if task.Job.Run.IsForkPullRequest && task.Job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget { - // ignore secrets for fork pull request - // 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 - } - } - - if _, ok := secrets["GITHUB_TOKEN"]; !ok { - secrets["GITHUB_TOKEN"] = task.Token - } - if _, ok := secrets["GITEA_TOKEN"]; !ok { - secrets["GITEA_TOKEN"] = task.Token - } - - 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) @@ -146,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. @@ -185,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,19 +155,16 @@ func findTaskNeeds(ctx context.Context, task *actions_model.ActionTask) (map[str if len(task.Job.Needs) == 0 { return nil, nil } - needs := map[string]struct{}{} - for _, v := range task.Job.Needs { - needs[v] = struct{}{} - } + 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) } ret := make(map[string]*runnerv1.TaskNeed, len(needs)) for _, job := range jobs { - if _, ok := needs[job.JobID]; !ok { + if !needs.Contains(job.JobID) { continue } if job.TaskID == 0 || !job.Status.IsDone() { diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go index 51a5c784e0..dae9c3dfcb 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" ) @@ -31,7 +31,7 @@ func apiError(ctx *context.Context, status int, obj any) { } func GetRepositoryKey(ctx *context.Context) { - _, pub, err := alpine_service.GetOrCreateKeyPair(ctx.Package.Owner.ID) + _, pub, err := alpine_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -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 2ba35e2138..5e3cbac8f9 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 8c370339cd..140e532efd 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 1ea453f1f4..a790e9a363 100644 --- a/routers/api/packages/chef/auth.go +++ b/routers/api/packages/chef/auth.go @@ -4,9 +4,11 @@ package chef import ( + "context" "crypto" "crypto/rsa" "crypto/sha1" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" @@ -16,6 +18,7 @@ import ( "net/http" "path" "regexp" + "slices" "strconv" "strings" "time" @@ -24,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 ( @@ -36,6 +37,8 @@ var ( algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`) versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`) authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`) + + _ auth.Method = &Auth{} ) // Documentation: @@ -60,7 +63,7 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS return nil, nil } - pub, err := getUserPublicKey(u) + pub, err := getUserPublicKey(req.Context(), u) if err != nil { return nil, err } @@ -90,8 +93,8 @@ func getUserFromRequest(req *http.Request) (*user_model.User, error) { return user_model.GetUserByName(req.Context(), username) } -func getUserPublicKey(u *user_model.User) (crypto.PublicKey, error) { - pubKey, err := user_model.GetSetting(u.ID, chef_module.SettingPublicPem) +func getUserPublicKey(ctx context.Context, u *user_model.User) (crypto.PublicKey, error) { + pubKey, err := user_model.GetSetting(ctx, u.ID, chef_module.SettingPublicPem) if err != nil { return nil, err } @@ -263,7 +266,7 @@ func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error { } } - if !util.SliceEqual(out[skip:], data) { + if !slices.Equal(out[skip:], data) { return fmt.Errorf("could not verify signature") } diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go index 908f9fc4be..b49f4e9d0a 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 75bbfdf4d3..a045da40de 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/auth.go b/routers/api/packages/conan/auth.go index ca02d61e76..521fa12372 100644 --- a/routers/api/packages/conan/auth.go +++ b/routers/api/packages/conan/auth.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/services/packages" ) +var _ auth.Method = &Auth{} + type Auth struct{} func (a *Auth) Name() string { diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go index d7349a84b2..c45e085a4d 100644 --- a/routers/api/packages/conan/conan.go +++ b/routers/api/packages/conan/conan.go @@ -15,14 +15,14 @@ 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" - "code.gitea.io/gitea/modules/notification" 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" ) @@ -326,13 +326,8 @@ func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey } defer buf.Close() - if buf.Size() == 0 { - // ignore empty uploads, second request contains content - jsonResponse(ctx, http.StatusOK, nil) - return - } - isConanfileFile := filename == conanfileFile + isConaninfoFile := filename == conaninfoFile pci := &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -364,7 +359,7 @@ func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey pfci.Properties[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault() } - if isConanfileFile || filename == conaninfoFile { + if isConanfileFile || isConaninfoFile { if isConanfileFile { metadata, err := conan_module.ParseConanfile(buf) if err != nil { @@ -413,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: @@ -663,7 +659,7 @@ func deleteRecipeOrPackage(apictx *context.Context, rref *conan_module.RecipeRef } if versionDeleted { - notification.NotifyPackageDelete(apictx, apictx.Doer, pd) + notify_service.PackageDelete(apictx, apictx.Doer, pd) } return nil diff --git a/routers/api/packages/conan/search.go b/routers/api/packages/conan/search.go index 2bcf9df162..7370c702cd 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 0bf0fc1f62..30c80fc15e 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/auth.go b/routers/api/packages/container/auth.go index 6fb32c389d..1c7afa95ff 100644 --- a/routers/api/packages/container/auth.go +++ b/routers/api/packages/container/auth.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/services/packages" ) +var _ auth.Method = &Auth{} + type Auth struct{} func (a *Auth) Name() string { diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 7bd5cadaaf..e519766142 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/container/manifest.go b/routers/api/packages/container/manifest.go index 6678ed20bc..4a79a58f51 100644 --- a/routers/api/packages/container/manifest.go +++ b/routers/api/packages/container/manifest.go @@ -17,10 +17,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/notification" packages_module "code.gitea.io/gitea/modules/packages" container_module "code.gitea.io/gitea/modules/packages/container" "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" packages_service "code.gitea.io/gitea/services/packages" digest "github.com/opencontainers/go-digest" @@ -306,7 +306,7 @@ func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *package return err } - notification.NotifyPackageCreate(ctx, doer, pd) + notify_service.PackageCreate(ctx, doer, pd) return nil } diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go index 0ef6eff88d..2cec75294f 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 a6da1a11a8..241de3ac5d 100644 --- a/routers/api/packages/debian/debian.go +++ b/routers/api/packages/debian/debian.go @@ -13,12 +13,12 @@ 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/notification" 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" ) @@ -30,7 +30,7 @@ func apiError(ctx *context.Context, status int, obj any) { } func GetRepositoryKey(ctx *context.Context) { - _, pub, err := debian_service.GetOrCreateKeyPair(ctx.Package.Owner.ID) + _, pub, err := debian_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -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: @@ -296,7 +297,7 @@ func DeletePackageFile(ctx *context.Context) { } if pd != nil { - notification.NotifyPackageDelete(ctx, ctx.Doer, pd) + notify_service.PackageDelete(ctx, ctx.Doer, pd) } if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, architecture); err != nil { diff --git a/routers/api/packages/generic/generic.go b/routers/api/packages/generic/generic.go index c5866ef9c3..8232931134 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 0000000000..1acaafe576 --- /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 bacdc4ec62..d658066bb4 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 9097adf29e..efdb83ec0e 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 aadb10376c..cdb64109ad 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 6328e226ab..27f0578db7 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 8470874884..f8e839c424 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 d1e271f23f..84acfffae2 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/auth.go b/routers/api/packages/nuget/auth.go index 54b33d89c0..1bb68d059b 100644 --- a/routers/api/packages/nuget/auth.go +++ b/routers/api/packages/nuget/auth.go @@ -13,6 +13,8 @@ import ( "code.gitea.io/gitea/services/auth" ) +var _ auth.Method = &Auth{} + type Auth struct{} func (a *Auth) Name() string { @@ -21,7 +23,7 @@ func (a *Auth) Name() string { // https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#request-parameters func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { - token, err := auth_model.GetAccessTokenBySHA(req.Header.Get("X-NuGet-ApiKey")) + token, err := auth_model.GetAccessTokenBySHA(req.Context(), req.Header.Get("X-NuGet-ApiKey")) if err != nil { if !(auth_model.IsErrAccessTokenNotExist(err) || auth_model.IsErrAccessTokenEmpty(err)) { log.Error("GetAccessTokenBySHA: %v", err) @@ -37,7 +39,7 @@ func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataS } token.UpdatedUnix = timeutil.TimeStampNow() - if err := auth_model.UpdateAccessToken(token); err != nil { + if err := auth_model.UpdateAccessToken(req.Context(), token); err != nil { log.Error("UpdateAccessToken: %v", err) } diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go index 6f63c1d4c2..c28bc6c9d9 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 ef07836b88..f87df52a29 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 d97b894bbe..7824db1823 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 930b20208a..4de361c214 100644 --- a/routers/api/packages/rpm/rpm.go +++ b/routers/api/packages/rpm/rpm.go @@ -13,14 +13,14 @@ 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" - "code.gitea.io/gitea/modules/notification" 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`) @@ -45,7 +52,7 @@ gpgkey=`+url+`/repository.key`) // Gets or creates the PGP public key used to sign repository metadata files func GetRepositoryKey(ctx *context.Context) { - _, pub, err := rpm_service.GetOrCreateKeyPair(ctx.Package.Owner.ID) + _, pub, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -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 @@ -247,10 +290,10 @@ func DeletePackageFile(webctx *context.Context) { } if pd != nil { - notification.NotifyPackageDelete(webctx, webctx.Doer, pd) + 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 88d70f10bd..d2fbcd01f0 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 bd4b8095c2..a9da3ea9c2 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 9fe7ab56f6..98a81da368 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 bc6b82b179..995a148f0b 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" @@ -66,7 +66,7 @@ func Person(ctx *context.APIContext) { person.PublicKey.ID = ap.IRI(link + "#main-key") person.PublicKey.Owner = ap.IRI(link) - publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser) + publicKeyPem, err := activitypub.GetPublicKey(ctx, ctx.ContextUser) if err != nil { ctx.ServerError("GetPublicKey", err) return diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index 3f60ed7776..59ebc74b89 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 ccd8be9171..a4708fe032 100644 --- a/routers/api/v1/admin/adopt.go +++ b/routers/api/v1/admin/adopt.go @@ -8,10 +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" - repo_module "code.gitea.io/gitea/modules/repository" "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" ) @@ -109,7 +108,7 @@ func AdoptRepository(ctx *context.APIContext) { ctx.NotFound() return } - if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_module.CreateRepoOptions{ + if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ Name: repoName, IsPrivate: true, }); err != nil { diff --git a/routers/api/v1/admin/cron.go b/routers/api/v1/admin/cron.go index cc8c6c9e23..e1ca6048c9 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 8d0491e070..ba963e9f69 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" ) @@ -37,7 +37,7 @@ func GetAllEmails(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - emails, maxResults, err := user_model.SearchEmails(&user_model.SearchEmailOptions{ + emails, maxResults, err := user_model.SearchEmails(ctx, &user_model.SearchEmailOptions{ Keyword: ctx.Params(":email"), ListOptions: listOptions, }) diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go index 8a095a7def..4c168b55bf 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 6d50a12674..a5c299bbf0 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) || @@ -101,7 +101,7 @@ func GetAllOrgs(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - users, maxResults, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeOrganization, OrderBy: db.SearchOrderByAlphabetically, diff --git a/routers/api/v1/admin/repo.go b/routers/api/v1/admin/repo.go index a4895f260b..c119d5390a 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 0000000000..329242d9f6 --- /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 4f1e9a3f53..87a5b28fad 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 + } + + 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 } - ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) - return } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, - } - - if form.Restricted != nil { - overwriteDefault.IsRestricted = util.OptionalBoolOf(*form.Restricted) + 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(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() { + 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 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)) + case errors.Is(err, password.ErrComplexity): + ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) + case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err): + ctx.Error(http.StatusBadRequest, "PasswordIsPwned", err) + default: + ctx.Error(http.StatusInternalServerError, "UpdateAuth", err) + } return } - if len(form.Password) != 0 { - if len(form.Password) < setting.MinPasswordLength { - 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") - 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 - } - } - - 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) { @@ -450,7 +421,7 @@ func SearchUsers(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - users, maxResults, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, LoginName: ctx.FormTrim("login_name"), @@ -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 0000000000..bacd1f809b --- /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 6424931a47..e870378c4b 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" @@ -90,8 +92,9 @@ import ( "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" "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 @@ -147,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) @@ -164,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) { @@ -314,10 +317,6 @@ func reqToken() func(ctx *context.APIContext) { return } - if ctx.IsBasicAuth { - ctx.CheckForOTP() - return - } if ctx.IsSigned { return } @@ -333,13 +332,15 @@ func reqExploreSignIn() func(ctx *context.APIContext) { } } -func reqBasicAuth() func(ctx *context.APIContext) { +func reqBasicOrRevProxyAuth() func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if ctx.IsSigned && setting.Service.EnableReverseProxyAuthAPI && ctx.Data["AuthedMethod"].(string) == auth.ReverseProxyMethodName { + return + } if !ctx.IsBasicAuth { ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth required") return } - ctx.CheckForOTP() } } @@ -363,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) { @@ -550,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) { @@ -661,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 } } @@ -686,23 +697,123 @@ 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{}, &auth.HTTPSign{}, &auth.Basic{}, // FIXME: this should be removed once we don't allow basic auth in API ) - specialAdd(group) + if setting.Service.EnableReverseProxyAuthAPI { + group.Add(&auth.ReverseProxy{}) + } + + if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { + group.Add(&auth.SSPI{}) // it MUST be the last, see the comment of SSPI + } return group } +func apiAuth(authMethod auth.Method) func(*context.APIContext) { + return func(ctx *context.APIContext) { + ar, err := common.AuthShared(ctx.Base, nil, authMethod) + if err != nil { + ctx.Error(http.StatusUnauthorized, "APIAuth", err) + return + } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth + } +} + +// verifyAuthWithOptions checks authentication according to options +func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + // Check prohibit login users. + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is not activated.", + }) + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is prohibited from signing in, please contact your site administrator.", + }) + return + } + + if ctx.Doer.MustChangePassword { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password", + }) + return + } + } + + // Redirect to dashboard if user tries to visit any non-login page. + if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { + ctx.Redirect(setting.AppSubURL + "/") + return + } + + if options.SignInRequired { + if !ctx.IsSigned { + // Restrict API calls with error message. + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in user is allowed to call APIs.", + }) + return + } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "This account is not activated.", + }) + return + } + } + + if options.AdminRequired { + if !ctx.Doer.IsAdmin { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "You have no permission to request for this.", + }) + return + } + } + } +} + +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() @@ -710,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...), @@ -721,10 +830,12 @@ func Routes() *web.Route { } m.Use(context.APIContexter()) - // Get user from session if logged in. - m.Use(auth.APIAuth(buildAuthGroup())) + m.Use(checkDeprecatedAuthMethods) - m.Use(auth.VerifyAuthWithOptionsAPI(&auth.VerifyOptions{ + // Get user from session if logged in. + m.Use(apiAuth(buildAuthGroup())) + + m.Use(verifyAuthWithOptions(&common.VerifyOptions{ SignInRequired: setting.Service.RequireSignInView, })) @@ -743,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)) } @@ -776,11 +887,11 @@ func Routes() *web.Route { // Notifications (requires 'notifications' scope) m.Group("/notifications", func() { m.Combo(""). - Get(notify.ListNotifications). + Get(reqToken(), notify.ListNotifications). Put(reqToken(), notify.ReadNotifications) - m.Get("/new", notify.NewAvailable) + m.Get("/new", reqToken(), notify.NewAvailable) m.Combo("/threads/{id}"). - Get(notify.GetThread). + Get(reqToken(), notify.GetThread). Patch(reqToken(), notify.ReadThread) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) @@ -800,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) - }, reqBasicAuth()) + }, reqSelfOrAdmin(), reqBasicOrRevProxyAuth()) m.Get("/activities/feeds", user.ListUserActivityFeeds) - }, context_service.UserAssignmentAPI()) + }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) // Users (requires user scope) @@ -821,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) @@ -836,6 +947,28 @@ func Routes() *web.Route { Post(bind(api.CreateEmailOption{}), user.AddEmail). Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) + // 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) m.Group("/following", func() { m.Get("", user.ListMyFollowing) @@ -843,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) @@ -903,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) @@ -933,6 +1075,26 @@ func Routes() *web.Route { m.Post("/accept", repo.AcceptTransfer) m.Post("/reject", repo.RejectTransfer) }, reqToken()) + 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) m.Group("/{id}", func() { @@ -976,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). @@ -1020,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) @@ -1045,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()) @@ -1089,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). @@ -1099,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() { @@ -1113,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) @@ -1162,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() { @@ -1277,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() { @@ -1298,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.CreateOrUpdateOrgSecret). - Delete(reqToken(), reqOrgOwnership(), org.DeleteOrgSecret) + 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) @@ -1311,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) @@ -1334,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). @@ -1376,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) @@ -1394,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 e44271ba14..0000000000 --- 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 3514e21baa..0000000000 --- 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 7c7fe4b125..dffd771752 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 0e0ca39fc5..cc11f37626 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 65f63468cf..2a980f5084 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 7b24b353b6..9699c79368 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 bab06b3e66..5236fd06ae 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -13,16 +13,16 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/test" "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) { @@ -34,7 +34,7 @@ func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, r Wiki: true, FilePath: filePath, } - ctx, resp := test.MockAPIContext(t, "POST /api/v1/markup") + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") web.SetForm(ctx, &options) Markup(ctx) assert.Equal(t, responseBody, resp.Body.String()) @@ -50,7 +50,7 @@ func testRenderMarkdown(t *testing.T, mode, text, responseBody string, responseC Context: Repo, Wiki: true, } - ctx, resp := test.MockAPIContext(t, "POST /api/v1/markdown") + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") web.SetForm(ctx, &options) Markdown(ctx) assert.Equal(t, responseBody, resp.Body.String()) @@ -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

`, } @@ -162,7 +162,7 @@ func TestAPI_RenderSimple(t *testing.T) { Text: "", Context: Repo, } - ctx, resp := test.MockAPIContext(t, "POST /api/v1/markdown") + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") for i := 0; i < len(simpleCases); i += 2 { options.Text = simpleCases[i] web.SetForm(ctx, &options) @@ -174,7 +174,7 @@ func TestAPI_RenderSimple(t *testing.T) { func TestAPI_RenderRaw(t *testing.T) { setting.AppURL = AppURL - ctx, resp := test.MockAPIContext(t, "POST /api/v1/markdown") + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") for i := 0; i < len(simpleCases); i += 2 { ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i])) MarkdownRaw(ctx) diff --git a/routers/api/v1/misc/nodeinfo.go b/routers/api/v1/misc/nodeinfo.go index 319c3483e1..3bd80de5c1 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,20 +29,19 @@ 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(nil)) + usersTotal := int(user_model.CountUsers(ctx, nil)) now := time.Now() timeOneMonthAgo := now.AddDate(0, -1, 0).Unix() timeHaveYearAgo := now.AddDate(0, -6, 0).Unix() - usersActiveMonth := int(user_model.CountUsers(&user_model.CountUserFilter{LastLoginSince: &timeOneMonthAgo})) - usersActiveHalfyear := int(user_model.CountUsers(&user_model.CountUserFilter{LastLoginSince: &timeHaveYearAgo})) + usersActiveMonth := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeOneMonthAgo})) + 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 2ca9813e15..24a46c1e70 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 83fa35219a..e3b43a0e6b 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 b22ea8a771..46b3c7f5e7 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 e16c54a2c0..8d97e8a3f8 100644 --- a/routers/api/v1/notify/repo.go +++ b/routers/api/v1/notify/repo.go @@ -9,9 +9,10 @@ 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/log" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -108,18 +109,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 +128,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 @@ -202,7 +203,7 @@ func ReadRepoNotifications(ctx *context.APIContext) { 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 +223,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 6a1bce4de4..8e12d359cb 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 a9c6b43617..879f484cce 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 b3cb0b81a6..e34c68dfc9 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" ) @@ -33,6 +33,8 @@ func UpdateAvatar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) content, err := base64.StdEncoding.DecodeString(form.Image) @@ -41,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) } @@ -65,7 +67,9 @@ func DeleteAvatar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" - err := user_service.DeleteAvatar(ctx.Org.Organization.AsUser()) + // "404": + // "$ref": "#/responses/notFound" + 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 0000000000..69a5222a20 --- /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 a6ea618a7d..c1dc0519ea 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" ) @@ -37,6 +37,8 @@ func ListHooks(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/HookList" + // "404": + // "$ref": "#/responses/notFound" utils.ListOwnerHooks( ctx, @@ -66,6 +68,8 @@ func GetHook(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" hook, err := utils.GetOwnerHook(ctx, ctx.ContextUser.ID, ctx.ParamsInt64("id")) if err != nil { @@ -103,6 +107,8 @@ func CreateHook(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" utils.AddOwnerHook( ctx, @@ -139,6 +145,8 @@ func EditHook(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" utils.EditOwnerHook( ctx, @@ -170,6 +178,8 @@ func DeleteHook(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" utils.DeleteOwnerHook( ctx, diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go index 9ef28d4db9..b5ec54ccf4 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" ) @@ -41,6 +41,8 @@ func ListLabels(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/LabelList" + // "404": + // "$ref": "#/responses/notFound" labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), utils.GetListOptions(ctx)) if err != nil { @@ -48,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 @@ -80,6 +82,8 @@ func CreateLabel(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.CreateLabelOption) @@ -128,6 +132,8 @@ func GetLabel(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" var ( label *issues_model.Label @@ -179,6 +185,8 @@ func EditLabel(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.EditLabelOption) @@ -210,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 } @@ -238,8 +246,10 @@ func DeleteLabel(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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 e4afd7f3c6..9db9ad964b 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,13 +25,13 @@ 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 } - members, _, err := organization.FindOrgMembers(opts) + members, _, err := organization.FindOrgMembers(ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -70,10 +70,12 @@ func ListMembers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" 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 @@ -107,6 +109,8 @@ func ListPublicMembers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" listMembers(ctx, true) } @@ -140,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 { @@ -190,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 @@ -225,6 +229,8 @@ func PublicizeMember(ctx *context.APIContext) { // description: membership publicized // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" userToPublicize := user.GetUserByParams(ctx) if ctx.Written() { @@ -234,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 @@ -265,6 +271,8 @@ func ConcealMember(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" userToConceal := user.GetUserByParams(ctx) if ctx.Written() { @@ -274,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 @@ -303,12 +311,14 @@ func DeleteMember(ctx *context.APIContext) { // responses: // "204": // description: member removed + // "404": + // "$ref": "#/responses/notFound" member := user.GetUserByParams(ctx) 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 b0666c87f8..e848d95181 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 } @@ -70,6 +67,8 @@ func ListMyOrgs(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/OrganizationList" + // "404": + // "$ref": "#/responses/notFound" listUserOrgs(ctx, ctx.Doer) } @@ -98,6 +97,8 @@ func ListUserOrgs(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/OrganizationList" + // "404": + // "$ref": "#/responses/notFound" listUserOrgs(ctx, ctx.ContextUser) } @@ -141,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 @@ -160,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 @@ -199,7 +200,7 @@ func GetAll(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - publicOrgs, maxResults, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + publicOrgs, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ Actor: ctx.Doer, ListOptions: listOptions, Type: user_model.UserTypeOrganization, @@ -264,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) || @@ -295,6 +296,8 @@ func Get(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Organization" + // "404": + // "$ref": "#/responses/notFound" if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { ctx.NotFound("HasOrgOrUserVisible", nil) @@ -334,28 +337,32 @@ func Edit(ctx *context.APIContext) { // responses: // "200": // "$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 @@ -374,8 +381,10 @@ func Delete(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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 } @@ -419,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 0000000000..2a52bd8778 --- /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 65% rename from routers/api/v1/org/action.go rename to routers/api/v1/org/secrets.go index ee18cca26d..abb6bb26c4 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/secrets.go @@ -4,14 +4,17 @@ package org 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/routers/web/shared/actions" + "code.gitea.io/gitea/services/context" + secret_service "code.gitea.io/gitea/services/secrets" ) // ListActionsSecrets list an organization's actions secrets @@ -38,24 +41,15 @@ func ListActionsSecrets(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/SecretList" + // "404": + // "$ref": "#/responses/notFound" - listActionsSecrets(ctx) -} - -// listActionsSecrets list an organization's actions secrets -func listActionsSecrets(ctx *context.APIContext) { opts := &secret_model.FindSecretsOptions{ OwnerID: ctx.Org.Organization.ID, 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 @@ -74,7 +68,7 @@ func listActionsSecrets(ctx *context.APIContext) { } // create or update one secret of the organization -func CreateOrUpdateOrgSecret(ctx *context.APIContext) { +func CreateOrUpdateSecret(ctx *context.APIContext) { // swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret // --- // summary: Create or Update a secret value in an organization @@ -104,38 +98,32 @@ func CreateOrUpdateOrgSecret(ctx *context.APIContext) { // description: response when updating a secret // "400": // "$ref": "#/responses/error" - // "403": - // "$ref": "#/responses/forbidden" - secretName := ctx.Params(":secretname") - if err := actions.NameRegexMatch(secretName); err != nil { - ctx.Error(http.StatusBadRequest, "CreateOrUpdateOrgSecret", err) - return - } + // "404": + // "$ref": "#/responses/notFound" + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) - err := secret_model.UpdateSecret( - ctx, ctx.Org.Organization.ID, 0, secretName, opt.Data, - ) - if secret_model.IsErrSecretNotFound(err) { - _, err := secret_model.InsertEncryptedSecret( - ctx, ctx.Org.Organization.ID, 0, secretName, actions.ReserveLineBreakForTextarea(opt.Data), - ) - if err != nil { - ctx.Error(http.StatusInternalServerError, "InsertEncryptedSecret", err) - return - } - ctx.Status(http.StatusCreated) - return - } + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"), opt.Data) if err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateSecret", err) + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } return } - ctx.Status(http.StatusNoContent) + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } } -// DeleteOrgSecret delete one secret of the organization -func DeleteOrgSecret(ctx *context.APIContext) { +// DeleteSecret delete one secret of the organization +func DeleteSecret(ctx *context.APIContext) { // swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret // --- // summary: Delete a secret in an organization @@ -157,18 +145,20 @@ func DeleteOrgSecret(ctx *context.APIContext) { // responses: // "204": // description: delete one secret of the organization - // "403": - // "$ref": "#/responses/forbidden" - secretName := ctx.Params(":secretname") - err := secret_model.DeleteSecret( - ctx, ctx.Org.Organization.ID, 0, secretName, - ) - if secret_model.IsErrSecretNotFound(err) { - ctx.NotFound(err) - return - } + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname")) if err != nil { - ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } return } diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 0e11acc901..015af774e3 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -15,14 +15,16 @@ 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" ) // ListTeams list all the teams of an organization @@ -49,8 +51,10 @@ func ListTeams(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/TeamList" + // "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, }) @@ -89,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, }) @@ -125,6 +129,8 @@ func GetTeam(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" apiTeam, err := convert.ToTeam(ctx, ctx.Org.Team, true) if err != nil { @@ -203,6 +209,8 @@ func CreateTeam(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.CreateTeamOption) @@ -232,7 +240,7 @@ func CreateTeam(ctx *context.APIContext) { attachAdminTeamUnits(team) } - if err := models.NewTeam(team); err != nil { + if err := models.NewTeam(ctx, team); err != nil { if organization.IsErrTeamAlreadyExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) } else { @@ -241,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 @@ -271,6 +279,8 @@ func EditTeam(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditTeamOption) team := ctx.Org.Team @@ -321,7 +331,7 @@ func EditTeam(ctx *context.APIContext) { attachAdminTeamUnits(team) } - if err := models.UpdateTeam(team, isAuthChanged, isIncludeAllChanged); err != nil { + if err := models.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Error(http.StatusInternalServerError, "EditTeam", err) return } @@ -349,8 +359,10 @@ func DeleteTeam(ctx *context.APIContext) { // responses: // "204": // description: team deleted + // "404": + // "$ref": "#/responses/notFound" - if err := models.DeleteTeam(ctx.Org.Team); err != nil { + if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteTeam", err) return } @@ -382,6 +394,8 @@ func GetTeamMembers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID) if err != nil { @@ -473,6 +487,8 @@ func AddTeamMember(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" @@ -480,8 +496,12 @@ func AddTeamMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.AddTeamMember(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) @@ -517,7 +537,7 @@ func RemoveTeamMember(ctx *context.APIContext) { return } - if err := models.RemoveTeamMember(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 } @@ -549,6 +569,8 @@ func GetTeamRepos(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" team := ctx.Org.Team teamRepos, err := organization.GetTeamRepositories(ctx, &organization.SearchTeamRepoOptions{ @@ -623,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() @@ -664,6 +686,8 @@ func AddTeamRepository(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" repo := getRepositoryByParams(ctx) if ctx.Written() { @@ -676,7 +700,7 @@ func AddTeamRepository(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") return } - if err := org_service.TeamAddRepository(ctx.Org.Team, repo); err != nil { + if err := org_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil { ctx.Error(http.StatusInternalServerError, "TeamAddRepository", err) return } @@ -714,6 +738,8 @@ func RemoveTeamRepository(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" repo := getRepositoryByParams(ctx) if ctx.Written() { @@ -726,7 +752,7 @@ func RemoveTeamRepository(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") return } - if err := models.RemoveRepository(ctx.Org.Team, repo.ID); err != nil { + if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, repo.ID); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveRepository", err) return } @@ -774,6 +800,8 @@ func SearchTeam(ctx *context.APIContext) { // type: array // items: // "$ref": "#/definitions/Team" + // "404": + // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) @@ -789,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 0000000000..eaf7bdc45b --- /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 5129c7d4f0..b38aa13167 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" ) @@ -48,6 +48,8 @@ func ListPackages(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/PackageList" + // "404": + // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) @@ -58,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 { @@ -162,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 new file mode 100644 index 0000000000..03321d956d --- /dev/null +++ b/routers/api/v1/repo/action.go @@ -0,0 +1,425 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +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" + secret_service "code.gitea.io/gitea/services/secrets" +) + +// create or update one secret of the repository +func CreateOrUpdateSecret(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/secrets/{secretname} repository updateRepoSecret + // --- + // summary: Create or Update a secret value in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateSecretOption" + // responses: + // "201": + // description: response when creating a secret + // "204": + // description: response when updating a secret + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + owner := ctx.Repo.Owner + repo := ctx.Repo.Repository + + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, owner.ID, repo.ID, ctx.Params("secretname"), opt.Data) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } + return + } + + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// DeleteSecret delete one secret of the repository +func DeleteSecret(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/secrets/{secretname} repository deleteRepoSecret + // --- + // summary: Delete a secret in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // responses: + // "204": + // description: delete one secret of the organization + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + owner := ctx.Repo.Owner + repo := ctx.Repo.Repository + + err := secret_service.DeleteSecretByName(ctx, owner.ID, repo.ID, ctx.Params("secretname")) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } + return + } + + 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 48bd143d0c..698337ffd2 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" ) @@ -38,6 +38,8 @@ func UpdateAvatar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption) content, err := base64.StdEncoding.DecodeString(form.Image) @@ -75,6 +77,8 @@ func DeleteAvatar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go index d0004f0686..3b116666ea 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" ) @@ -38,6 +38,8 @@ func GetBlob(ctx *context.APIContext) { // "$ref": "#/responses/GitBlobResponse" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" sha := ctx.Params("sha") if len(sha) == 0 { diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index cdc176b8e4..5e6b6a8658 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 @@ -447,7 +437,7 @@ func GetBranchProtection(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToBranchProtection(bp)) + ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp)) } // ListBranchProtections list branch protections for a repo @@ -480,7 +470,7 @@ func ListBranchProtections(ctx *context.APIContext) { } apiBps := make([]*api.BranchProtection, len(bps)) for i := range bps { - apiBps[i] = convert.ToBranchProtection(bps[i]) + apiBps[i] = convert.ToBranchProtection(ctx, bps[i]) } ctx.JSON(http.StatusOK, apiBps) @@ -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 @@ -688,7 +681,7 @@ func CreateBranchProtection(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToBranchProtection(bp)) + ctx.JSON(http.StatusCreated, convert.ToBranchProtection(ctx, bp)) } // EditBranchProtection edits a branch protection for a repo @@ -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 @@ -960,7 +959,7 @@ func EditBranchProtection(ctx *context.APIContext) { return } - ctx.JSON(http.StatusOK, convert.ToBranchProtection(bp)) + ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp)) } // DeleteBranchProtection deletes a branch protection for a repo @@ -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 942d4c799f..4ce14f7d01 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -8,17 +8,17 @@ import ( "errors" "net/http" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/perm" 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" ) // ListCollaborators list a repository's collaborators @@ -50,14 +50,13 @@ func ListCollaborators(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "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 @@ -68,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) } @@ -154,6 +153,10 @@ func AddCollaborator(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -175,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 } @@ -215,6 +222,8 @@ func DeleteCollaborator(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -228,7 +237,7 @@ func DeleteCollaborator(ctx *context.APIContext) { return } - if err := models.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 } @@ -311,6 +320,8 @@ func GetReviewers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" reviewers, err := repo_model.GetReviewers(ctx, ctx.Repo.Repository, ctx.Doer.ID, 0) if err != nil { @@ -341,6 +352,8 @@ func GetAssignees(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) if err != nil { diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go index 20d4405d6d..d06a3b4e49 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 ea311c3202..156033f58a 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 f75153ab2d..a1e3c9804b 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" ) @@ -52,8 +52,10 @@ func ListForks(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" - forks, err := repo_model.GetForks(ctx.Repo.Repository, utils.GetListOptions(ctx)) + forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetForks", err) return @@ -99,6 +101,8 @@ func CreateFork(ctx *context.APIContext) { // "$ref": "#/responses/Repository" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "409": // description: The repository with the same name already exists. // "422": @@ -119,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 @@ -145,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 40bd355428..26ae84d08d 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" ) @@ -34,6 +34,8 @@ func ListGitHooks(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/GitHookList" + // "404": + // "$ref": "#/responses/notFound" hooks, err := ctx.Repo.GitRepo.Hooks() if err != nil { diff --git a/routers/api/v1/repo/git_ref.go b/routers/api/v1/repo/git_ref.go index 34d2dcfcc8..0fa58425b8 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 d0b77b5687..ffd2313591 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" ) @@ -50,19 +51,15 @@ func ListHooks(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/HookList" + // "404": + // "$ref": "#/responses/notFound" opts := &webhook.ListWebhookOptions{ ListOptions: utils.GetListOptions(ctx), 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 @@ -157,6 +154,8 @@ func TestHook(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" if ctx.Repo.Commit == nil { // if repo does not have any commits, then don't send a webhook @@ -224,6 +223,8 @@ func CreateHook(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" utils.AddRepoHook(ctx, web.GetForm(ctx).(*api.CreateHookOption)) } @@ -259,6 +260,8 @@ func EditHook(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditHookOption) hookID := ctx.ParamsInt64(":id") utils.EditRepoHook(ctx, form, hookID) @@ -293,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 b43a22cd55..37cf61c1ed 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/test" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -17,11 +17,11 @@ import ( func TestTestHook(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockAPIContext(t, "user2/repo1/wiki/_pages") + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/wiki/_pages") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) TestHook(ctx) assert.EqualValues(t, http.StatusNoContent, ctx.Resp.Status()) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index a08fdf5940..6934b34b24 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,17 +19,17 @@ 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/notification" + "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" ) // SearchIssues searches for issues across the repositories that the user has access to @@ -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) } } @@ -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 @@ -388,20 +389,22 @@ func ListIssues(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) 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") @@ -411,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 @@ -423,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 @@ -450,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 @@ -483,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 @@ -507,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) @@ -591,6 +610,10 @@ func GetIssue(ctx *context.APIContext) { } return } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, issue)) } @@ -623,10 +646,15 @@ func CreateIssue(ctx *context.APIContext) { // "$ref": "#/responses/Issue" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "412": // "$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) { @@ -681,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 } @@ -799,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 } @@ -822,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 } } @@ -831,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") @@ -859,11 +894,11 @@ func EditIssue(ctx *context.APIContext) { } if titleChanged { - notification.NotifyIssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) + notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) } if statusChangeComment != nil { - notification.NotifyIssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) + notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) } // Refetch from database to assign some automatic values @@ -986,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 ad83c206d9..d62e23aa02 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" ) @@ -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, @@ -189,7 +191,7 @@ func CreateIssueAttachment(ctx *context.APIContext) { issue.Attachments = append(issue.Attachments, attachment) - if err := issue_service.ChangeContent(issue, ctx.Doer, issue.Content); err != nil { + if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, issue.Content); err != nil { ctx.Error(http.StatusInternalServerError, "ChangeContent", err) return } @@ -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,13 +296,15 @@ func DeleteIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" attachment := getIssueAttachmentSafeWrite(ctx) if attachment == nil { return } - if err := repo_model.DeleteAttachment(attachment, true); err != nil { + if err := repo_model.DeleteAttachment(ctx, attachment, true); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) return } @@ -344,7 +350,7 @@ func getIssueAttachmentSafeRead(ctx *context.APIContext, issue *issues_model.Iss } func canUserWriteIssueAttachment(ctx *context.APIContext, issue *issues_model.Issue) bool { - canEditIssue := ctx.IsSigned && (ctx.Doer.ID == issue.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin()) && ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + canEditIssue := ctx.IsSigned && (ctx.Doer.ID == issue.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) if !canEditIssue { ctx.Error(http.StatusForbidden, "", "user should have permission to write issue") return false diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 1f8e16147f..070571ba62 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" ) @@ -58,6 +60,8 @@ func ListIssueComments(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/CommentList" + // "404": + // "$ref": "#/responses/notFound" before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { @@ -69,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{ @@ -84,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 @@ -155,6 +164,8 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/TimelineList" + // "404": + // "$ref": "#/responses/notFound" before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { @@ -258,6 +269,8 @@ func ListRepoIssueComments(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/CommentList" + // "404": + // "$ref": "#/responses/notFound" before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { @@ -265,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) @@ -279,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 @@ -295,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 @@ -350,6 +374,11 @@ func CreateIssueComment(ctx *context.APIContext) { // "$ref": "#/responses/Comment" // "403": // "$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 { @@ -357,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 } @@ -426,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 @@ -478,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) @@ -544,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 } @@ -557,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 } @@ -647,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 121e3f10e0..4096cbf07b 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,13 +308,14 @@ func DeleteIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/error" - + // "423": + // "$ref": "#/responses/repoArchivedError" attach := getIssueCommentAttachmentSafeWrite(ctx) if attach == nil { return } - if err := repo_model.DeleteAttachment(attach, true); err != nil { + if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) 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 b0eb208a32..a42920d4fd 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" ) @@ -52,6 +52,8 @@ func GetIssueDependencies(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" // If this issue's repository does not enable dependencies then there can be no dependencies by default if !ctx.Repo.Repository.IsDependenciesEnabled(ctx) { @@ -100,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 @@ -185,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) @@ -242,6 +247,10 @@ func RemoveIssueDependency(ctx *context.APIContext) { // responses: // "200": // "$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) @@ -303,6 +312,8 @@ func GetIssueBlocks(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" // We need to list the issues that DEPEND on this issue not the other way round // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant. @@ -335,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) { @@ -458,6 +471,8 @@ func RemoveIssueBlocking(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Issue" + // "404": + // "$ref": "#/responses/notFound" dependency := getParamsIssue(ctx) if ctx.Written() { @@ -564,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 @@ -590,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 a2814a03db..7d9f85d2aa 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" ) @@ -98,6 +98,8 @@ func AddIssueLabels(ctx *context.APIContext) { // "$ref": "#/responses/LabelList" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.IssueLabelsOption) issue, labels, err := prepareForReplaceOrAdd(ctx, *form) @@ -105,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 } @@ -154,6 +156,8 @@ func DeleteIssueLabel(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -182,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 } @@ -225,13 +229,15 @@ func ReplaceIssueLabels(ctx *context.APIContext) { // "$ref": "#/responses/LabelList" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.IssueLabelsOption) issue, labels, err := prepareForReplaceOrAdd(ctx, *form) if err != nil { 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 } @@ -274,6 +280,8 @@ func ClearIssueLabels(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -290,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 } @@ -309,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 6876739083..8fcf670fd0 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" ) @@ -199,6 +199,8 @@ func ListPinnedIssues(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, false) if err != nil { ctx.Error(http.StatusInternalServerError, "LoadPinnedIssues", err) @@ -229,6 +231,8 @@ func ListPinnedPullRequests(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/PullRequestList" + // "404": + // "$ref": "#/responses/notFound" issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, true) if err != nil { ctx.Error(http.StatusInternalServerError, "LoadPinnedPullRequests", err) @@ -236,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 @@ -290,6 +288,8 @@ func AreNewIssuePinsAllowed(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepoNewIssuePinsAllowed" + // "404": + // "$ref": "#/responses/notFound" pinsAllowed := api.NewIssuePinsAllowed{} var err error diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index 49e5a74aa3..3ff3d19f13 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 @@ -46,6 +48,8 @@ func GetIssueCommentReactions(ctx *context.APIContext) { // "$ref": "#/responses/ReactionList" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(":id")) if err != nil { @@ -59,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) { @@ -66,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 @@ -126,6 +136,8 @@ func PostIssueCommentReaction(ctx *context.APIContext) { // "$ref": "#/responses/Reaction" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditReactionOption) @@ -167,6 +179,8 @@ func DeleteIssueCommentReaction(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditReactionOption) @@ -184,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) { @@ -196,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{ @@ -219,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 @@ -268,6 +292,8 @@ func GetIssueReactions(ctx *context.APIContext) { // "$ref": "#/responses/ReactionList" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -284,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 @@ -345,6 +371,8 @@ func PostIssueReaction(ctx *context.APIContext) { // "$ref": "#/responses/Reaction" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditReactionOption) changeIssueReaction(ctx, *form, true) } @@ -384,6 +412,8 @@ func DeleteIssueReaction(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditReactionOption) changeIssueReaction(ctx, *form, false) } @@ -406,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{ @@ -417,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 } @@ -429,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 75fa863138..d9054e8f77 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 5a05471264..a535172462 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(), }) } @@ -273,7 +273,7 @@ func GetIssueSubscribers(ctx *context.APIContext) { userIDs = append(userIDs, iw.UserID) } - users, err := user_model.GetUsersByIDs(userIDs) + users, err := user_model.GetUsersByIDs(ctx, userIDs) if err != nil { ctx.Error(http.StatusInternalServerError, "GetUsersByIDs", err) return diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 3286a7c82e..c640515881 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" ) @@ -178,6 +178,8 @@ func AddTime(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.AddTimeOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -189,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 @@ -259,6 +261,8 @@ func ResetIssueTime(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -270,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 @@ -279,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) @@ -330,6 +334,8 @@ func DeleteTime(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -341,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 @@ -350,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) @@ -370,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 @@ -409,6 +415,8 @@ func ListTrackedTimesByUser(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { ctx.Error(http.StatusBadRequest, "", "time tracking disabled") @@ -497,6 +505,8 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { ctx.Error(http.StatusBadRequest, "", "time tracking disabled") diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go index 824880880a..88444a2625 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" ) @@ -80,21 +80,17 @@ func ListDeployKeys(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/DeployKeyList" + // "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 @@ -103,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 } @@ -144,6 +140,8 @@ func GetDeployKey(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/DeployKey" + // "404": + // "$ref": "#/responses/notFound" key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.ParamsInt64(":id")) if err != nil { @@ -155,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 } @@ -222,6 +226,8 @@ func CreateDeployKey(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/DeployKey" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -232,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 @@ -270,8 +276,10 @@ func DeleteDeploykey(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "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 fc9a16b58a..b6eb51fd20 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" ) @@ -46,6 +46,8 @@ func ListLabels(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/LabelList" + // "404": + // "$ref": "#/responses/notFound" labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), utils.GetListOptions(ctx)) if err != nil { @@ -53,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 @@ -90,6 +92,8 @@ func GetLabel(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" var ( l *issues_model.Label @@ -140,6 +144,8 @@ func CreateLabel(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -200,6 +206,8 @@ func EditLabel(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -232,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 } @@ -265,8 +273,10 @@ func DeleteLabel(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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 12f1761ad0..f1d5bbe45f 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 bc048505f4..451f34d72f 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 84327de5fb..2caaa130e8 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -17,20 +17,20 @@ 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" base "code.gitea.io/gitea/modules/migration" - "code.gitea.io/gitea/modules/notification" - 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/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" + notify_service "code.gitea.io/gitea/services/notify" + repo_service "code.gitea.io/gitea/services/repository" ) // Migrate migrate remote git repository to gitea @@ -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 @@ -170,7 +170,7 @@ func Migrate(ctx *context.APIContext) { opts.Releases = false } - repo, err := repo_module.CreateRepository(ctx.Doer, repoOwner, repo_module.CreateRepoOptions{ + repo, err := repo_service.CreateRepositoryDirectly(ctx, ctx.Doer, repoOwner, repo_service.CreateRepoOptions{ Name: opts.RepoName, Description: opts.Description, OriginalURL: form.CloneAddr, @@ -195,12 +195,12 @@ func Migrate(ctx *context.APIContext) { } if err == nil { - notification.NotifyMigrateRepository(ctx, ctx.Doer, repoOwner, repo) + notify_service.MigrateRepository(ctx, ctx.Doer, repoOwner, repo) return } if repo != nil { - if errDelete := models.DeleteRepository(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 b77fe8aca8..b9534016e4 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" ) @@ -55,15 +57,24 @@ func ListMilestones(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/MilestoneList" + // "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 } @@ -102,6 +113,8 @@ func GetMilestone(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" milestone := getMilestoneByIDOrName(ctx) if ctx.Written() { @@ -138,6 +151,8 @@ func CreateMilestone(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.CreateMilestoneOption) if form.Deadline == nil { @@ -157,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 } @@ -196,6 +211,8 @@ func EditMilestone(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditMilestoneOption) milestone := getMilestoneByIDOrName(ctx) if ctx.Written() { @@ -217,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 } @@ -248,13 +265,15 @@ func DeleteMilestone(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" m := getMilestoneByIDOrName(ctx) if ctx.Written() { 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 } @@ -276,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 60f1bfe0d3..864644e1ef 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" @@ -48,6 +48,8 @@ func MirrorSync(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" repo := ctx.Repo.Repository @@ -99,6 +101,8 @@ func PushMirrorSync(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "PushMirrorSync", "Mirror feature is disabled") @@ -154,6 +158,8 @@ func ListPushMirrors(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "GetPushMirrorsByRepoID", "Mirror feature is disabled") @@ -170,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) } @@ -211,6 +217,8 @@ func GetPushMirrorByName(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "GetPushMirrorByRemoteName", "Mirror feature is disabled") @@ -219,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 @@ -263,6 +278,8 @@ func AddPushMirror(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "AddPushMirror", "Mirror feature is disabled") @@ -343,15 +360,22 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro return } - pushMirror := &repo_model.PushMirror{ - RepoID: repo.ID, - Repo: repo, - RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), - Interval: interval, - SyncOnCommit: mirrorOption.SyncOnCommit, + remoteAddress, err := util.SanitizeURL(mirrorOption.RemoteAddress) + if err != nil { + ctx.ServerError("SanitizeURL", err) + return } - if err = repo_model.InsertPushMirror(ctx, pushMirror); err != nil { + pushMirror := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + Interval: interval, + SyncOnCommit: mirrorOption.SyncOnCommit, + RemoteAddress: remoteAddress, + } + + if err = db.Insert(ctx, pushMirror); err != nil { ctx.ServerError("InsertPushMirror", err) return } @@ -364,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 1bd66101f0..a4a1d4eab7 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 d2f055355d..0e0601b7d9 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" ) @@ -45,6 +45,10 @@ func ApplyDiffPatch(ctx *context.APIContext) { // responses: // "200": // "$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 58f2fc69ce..e43366ff14 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -21,10 +21,10 @@ 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/notification" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" @@ -32,10 +32,12 @@ 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" issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" ) @@ -92,14 +94,20 @@ func ListPullRequests(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/PullRequestList" + // "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.Repo.Repository.ID, &issues_model.PullRequestsOptions{ + 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 { @@ -184,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 @@ -274,10 +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 { @@ -418,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 } @@ -463,6 +564,8 @@ func EditPullRequest(ctx *context.APIContext) { // "$ref": "#/responses/PullRequest" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" // "412": @@ -516,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 } @@ -536,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) } @@ -547,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 } @@ -570,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 } @@ -583,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") @@ -594,11 +699,11 @@ func EditPullRequest(ctx *context.APIContext) { } if titleChanged { - notification.NotifyIssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) + notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) } if statusChangeComment != nil { - notification.NotifyIssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) + notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) } // change pull target branch @@ -617,12 +722,11 @@ 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 } - notification.NotifyPullRequestChangeTargetBranch(ctx, ctx.Doer, pr, form.Base) + notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, form.Base) } // update allow edits @@ -729,10 +833,14 @@ func MergePullRequest(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // "405": // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*forms.MergePullRequestForm) @@ -799,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 @@ -895,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): @@ -961,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() @@ -977,7 +1091,7 @@ func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) } // Check if current user has fork of repository or in the same repository. - headRepo := repo_model.GetForkedRepo(headUser.ID, baseRepo.ID) + headRepo := repo_model.GetForkedRepo(ctx, headUser.ID, baseRepo.ID) if headRepo == nil && !isSameRepo { log.Trace("parseCompareInfo[%d]: does not have fork or in same repository", baseRepo.ID) ctx.NotFound("GetForkedRepo") @@ -989,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, "", "" @@ -1188,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) @@ -1261,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" @@ -1283,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 @@ -1308,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) - 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}) + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") + + 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 @@ -1428,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, @@ -1448,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 a568cd565a..17bb2085b6 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 } @@ -819,6 +825,8 @@ func DismissPullReview(ctx *context.APIContext) { // "$ref": "#/responses/PullReview" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" opts := web.GetForm(ctx).(*api.DismissPullReviewOptions) @@ -860,6 +868,8 @@ func UnDismissPullReview(ctx *context.APIContext) { // "$ref": "#/responses/PullReview" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" dismissReview(ctx, "", false, false) @@ -870,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 } @@ -880,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 af7199d1d6..f0f3c0bbc7 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) @@ -150,10 +146,9 @@ func ListReleases(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/ReleaseList" + // "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, @@ -161,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 @@ -177,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 @@ -220,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) @@ -267,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 } @@ -313,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 } @@ -342,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 } @@ -391,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 a7d73acceb..59fd83e3a2 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 @@ -50,8 +69,14 @@ func GetReleaseAttachment(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Attachment" + // "404": + // "$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 { @@ -98,6 +123,8 @@ func ListReleaseAttachments(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/notFound" releaseID := ctx.ParamsInt64(":id") release, err := repo_model.GetReleaseByID(ctx, releaseID) @@ -129,6 +156,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // - application/json // consumes: // - multipart/form-data + // - application/octet-stream // parameters: // - name: owner // in: path @@ -155,12 +183,14 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // in: formData // description: attachment to upload // type: file - // required: true + // required: false // responses: // "201": // "$ref": "#/responses/Attachment" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" // Check if attachments are enabled if !setting.Attachment.Enabled { @@ -170,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 - } - defer file.Close() + var content io.ReadCloser + var filename string + var size int64 = -1 - filename := header.Filename - if query := ctx.FormString("name"); query != "" { - filename = query + 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") + } + + 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 { @@ -251,11 +291,17 @@ func EditReleaseAttachment(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.EditAttachmentOptions) // 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 { @@ -315,9 +361,15 @@ func DeleteReleaseAttachment(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // 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 { @@ -335,7 +387,7 @@ func DeleteReleaseAttachment(ctx *context.APIContext) { } // FIXME Should prove the existence of the given repo, but results in unnecessary database requests - if err := repo_model.DeleteAttachment(attach, true); err != nil { + if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) return } diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go index a03edfafcf..fec91164a2 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 7b0c954a73..822e368fa8 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -7,9 +7,12 @@ package repo 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" @@ -18,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" @@ -132,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)) @@ -166,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") @@ -235,23 +240,24 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre } // If the readme template does not exist, a 400 will be returned. - if opt.AutoInit && len(opt.Readme) > 0 && !util.SliceContains(repo_module.Readmes, opt.Readme) { + if opt.AutoInit && len(opt.Readme) > 0 && !slices.Contains(repo_module.Readmes, opt.Readme) { ctx.Error(http.StatusBadRequest, "", fmt.Errorf("readme template does not exist, available templates: %v", repo_module.Readmes)) return } - repo, err := repo_service.CreateRepository(ctx, ctx.Doer, owner, repo_module.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, + 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, + ObjectFormatName: opt.ObjectFormatName, }) if err != nil { if repo_model.IsErrRepoAlreadyExist(err) { @@ -354,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, @@ -395,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 @@ -450,6 +456,8 @@ func CreateOrgRepoDeprecated(ctx *context.APIContext) { // "$ref": "#/responses/validationError" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" CreateOrgRepo(ctx) } @@ -499,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 @@ -532,6 +540,8 @@ func Get(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Repository" + // "404": + // "$ref": "#/responses/notFound" if err := ctx.Repo.Repository.LoadAttributes(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "Repository.LoadAttributes", err) @@ -558,6 +568,8 @@ func GetByID(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/Repository" + // "404": + // "$ref": "#/responses/notFound" repo, err := repo_model.GetRepositoryByID(ctx, ctx.ParamsInt64(":id")) if err != nil { @@ -608,6 +620,8 @@ func Edit(ctx *context.APIContext) { // "$ref": "#/responses/Repository" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" @@ -627,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 } @@ -708,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 @@ -719,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 @@ -873,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, @@ -899,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 } @@ -928,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) } } @@ -973,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 } @@ -994,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) } } @@ -1099,11 +1145,13 @@ func Delete(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" 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 @@ -1146,12 +1194,13 @@ func GetIssueTemplates(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueTemplates" - ret, err := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetTemplatesFromDefaultBranch", err) - return + // "404": + // "$ref": "#/responses/notFound" + 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 @@ -1175,6 +1224,8 @@ func GetIssueConfig(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepoIssueConfig" + // "404": + // "$ref": "#/responses/notFound" issueConfig, _ := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) ctx.JSON(http.StatusOK, issueConfig) } @@ -1200,6 +1251,8 @@ func ValidateIssueConfig(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepoIssueConfigValidation" + // "404": + // "$ref": "#/responses/notFound" _, err := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) if err == nil { diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go index 7593a87c2c..8d6ca9e3b5 100644 --- a/routers/api/v1/repo/repo_test.go +++ b/routers/api/v1/repo/repo_test.go @@ -10,8 +10,8 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -19,9 +19,9 @@ import ( func TestRepoEdit(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockAPIContext(t, "user2/repo1") - test.LoadRepo(t, ctx, 1) - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) ctx.Repo.Owner = ctx.Doer description := "new description" website := "http://wwww.newwebsite.com" @@ -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, } @@ -65,9 +67,9 @@ func TestRepoEdit(t *testing.T) { func TestRepoEditNameChange(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockAPIContext(t, "user2/repo1") - test.LoadRepo(t, ctx, 1) - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) ctx.Repo.Owner = ctx.Doer name := "newname" opts := api.EditRepoOption{ diff --git a/routers/api/v1/repo/runners.go b/routers/api/v1/repo/runners.go new file mode 100644 index 0000000000..fe133b311d --- /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 e4cf0ffab6..99676de119 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" ) @@ -42,8 +42,10 @@ func ListStargazers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" - stargazers, err := repo_model.GetStargazers(ctx.Repo.Repository, utils.GetListOptions(ctx)) + stargazers, err := repo_model.GetStargazers(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetStargazers", err) return diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go index 028e3083c6..9e36ea0aed 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 @@ -48,6 +49,8 @@ func NewCommitStatus(ctx *context.APIContext) { // "$ref": "#/responses/CommitStatus" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.CreateStatusOption) sha := ctx.Params("sha") @@ -61,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 } @@ -117,6 +120,8 @@ func GetCommitStatuses(ctx *context.APIContext) { // "$ref": "#/responses/CommitStatusList" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" getCommitStatuses(ctx, ctx.Params("sha")) } @@ -169,6 +174,8 @@ func GetCommitStatusesByRef(ctx *context.APIContext) { // "$ref": "#/responses/CommitStatusList" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" filter := utils.ResolveRefOrSha(ctx, ctx.Params("ref")) if ctx.Written() { @@ -188,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"), }) @@ -245,6 +254,8 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) { // "$ref": "#/responses/CombinedStatus" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" sha := utils.ResolveRefOrSha(ctx, ctx.Params("ref")) if ctx.Written() { diff --git a/routers/api/v1/repo/subscriber.go b/routers/api/v1/repo/subscriber.go index 613fbee409..8584182857 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" ) @@ -42,8 +42,10 @@ func ListSubscribers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" - subscribers, err := repo_model.GetRepoWatchers(ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) + subscribers, err := repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "GetRepoWatchers", err) return diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index b28b6b0b91..a6908f3615 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" ) @@ -47,6 +47,8 @@ func ListTags(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/TagList" + // "404": + // "$ref": "#/responses/notFound" listOpts := utils.GetListOptions(ctx) @@ -93,6 +95,8 @@ func GetAnnotatedTag(ctx *context.APIContext) { // "$ref": "#/responses/AnnotatedTag" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" sha := ctx.Params("sha") if len(sha) == 0 { @@ -180,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 @@ -247,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() @@ -264,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 01292f18d8..0ecf3a39d8 100644 --- a/routers/api/v1/repo/teams.go +++ b/routers/api/v1/repo/teams.go @@ -7,11 +7,11 @@ import ( "fmt" "net/http" - "code.gitea.io/gitea/models" "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" ) // ListTeams list a repository's teams @@ -35,6 +35,8 @@ func ListTeams(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/TeamList" + // "404": + // "$ref": "#/responses/notFound" if !ctx.Repo.Owner.IsOrganization() { ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") @@ -97,7 +99,7 @@ func IsTeam(ctx *context.APIContext) { return } - if models.HasRepository(team, ctx.Repo.Repository.ID) { + if repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID) { apiTeam, err := convert.ToTeam(ctx, team) if err != nil { ctx.InternalServerError(err) @@ -140,6 +142,8 @@ func AddTeam(ctx *context.APIContext) { // "$ref": "#/responses/validationError" // "405": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" changeRepoTeam(ctx, true) } @@ -174,6 +178,8 @@ func DeleteTeam(ctx *context.APIContext) { // "$ref": "#/responses/validationError" // "405": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" changeRepoTeam(ctx, false) } @@ -192,20 +198,20 @@ func changeRepoTeam(ctx *context.APIContext, add bool) { return } - repoHasTeam := models.HasRepository(team, ctx.Repo.Repository.ID) + repoHasTeam := repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID) var err error if add { if repoHasTeam { ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name)) return } - err = org_service.TeamAddRepository(team, ctx.Repo.Repository) + err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository) } else { if !repoHasTeam { ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name)) return } - err = models.RemoveRepository(team, ctx.Repo.Repository.ID) + err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID) } if err != nil { ctx.InternalServerError(err) diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go index 8bf3012d48..9852caa989 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" ) @@ -45,13 +46,15 @@ func ListTopics(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/TopicNames" + // "404": + // "$ref": "#/responses/notFound" opts := &repo_model.FindTopicOptions{ ListOptions: utils.GetListOptions(ctx), 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 @@ -93,6 +96,8 @@ func UpdateTopics(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/invalidTopicsError" @@ -116,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) @@ -152,6 +157,8 @@ func AddTopic(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/invalidTopicsError" @@ -166,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 { @@ -181,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) @@ -217,6 +224,8 @@ func DeleteTopic(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/invalidTopicsError" @@ -230,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) @@ -271,13 +280,15 @@ func TopicSearch(ctx *context.APIContext) { // "$ref": "#/responses/TopicListResponse" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" opts := &repo_model.FindTopicOptions{ Keyword: ctx.FormString("q"), 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 8ff22a1193..776b336761 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 9df96204cb..353a996d5b 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" ) @@ -53,6 +53,8 @@ func GetTree(ctx *context.APIContext) { // "$ref": "#/responses/GitTreeResponse" // "400": // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" sha := ctx.Params(":sha") if len(sha) == 0 { diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index 7f3a7d0674..f18ea087c4 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -10,14 +10,15 @@ 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/notification" + "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" ) @@ -50,6 +51,10 @@ func NewWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateWikiPageOptions) @@ -85,7 +90,7 @@ func NewWikiPage(ctx *context.APIContext) { wikiPage := getWikiPage(ctx, wikiName) if !ctx.Written() { - notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) + notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) ctx.JSON(http.StatusCreated, wikiPage) } } @@ -124,6 +129,10 @@ func EditWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateWikiPageOptions) @@ -153,7 +162,7 @@ func EditWikiPage(ctx *context.APIContext) { wikiPage := getWikiPage(ctx, newWikiName) if !ctx.Written() { - notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) + notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) ctx.JSON(http.StatusOK, wikiPage) } } @@ -194,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, @@ -230,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")) @@ -242,7 +253,7 @@ func DeleteWikiPage(ctx *context.APIContext) { return } - notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) + notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) ctx.Status(http.StatusNoContent) } @@ -322,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))) @@ -465,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 02bda1309d..0ee81b96d5 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 0000000000..a1e65625ed --- /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 0000000000..c850ad7866 --- /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 3771780718..665f4d0b85 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 6f7859df62..cd551cbdfa 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 fb6d185ee7..e2ad511d2b 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 new file mode 100644 index 0000000000..bf78c2c864 --- /dev/null +++ b/routers/api/v1/user/action.go @@ -0,0 +1,353 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +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" + secret_service "code.gitea.io/gitea/services/secrets" +) + +// create or update one secret of the user scope +func CreateOrUpdateSecret(ctx *context.APIContext) { + // swagger:operation PUT /user/actions/secrets/{secretname} user updateUserSecret + // --- + // summary: Create or Update a secret value in a user scope + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateSecretOption" + // responses: + // "201": + // description: response when creating a secret + // "204": + // description: response when updating a secret + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.Params("secretname"), opt.Data) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } + return + } + + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// DeleteSecret delete one secret of the user scope +func DeleteSecret(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/secrets/{secretname} user deleteUserSecret + // --- + // summary: Delete a secret in a user scope + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // responses: + // "204": + // description: delete one secret of the user + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.Params("secretname")) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } + return + } + + 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 f89d53945f..88e314ed31 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(opts) - if err != nil { - ctx.InternalServerError(err) - return - } - tokens, err := auth_model.ListAccessTokens(opts) + tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts) if err != nil { ctx.InternalServerError(err) return @@ -95,15 +93,17 @@ 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, } - exist, err := auth_model.AccessTokenByNameExists(t) + exist, err := auth_model.AccessTokenByNameExists(ctx, t) if err != nil { ctx.InternalServerError(err) return @@ -120,7 +120,7 @@ func CreateAccessToken(ctx *context.APIContext) { } t.Scope = scope - if err := auth_model.NewAccessToken(t); err != nil { + if err := auth_model.NewAccessToken(ctx, t); err != nil { ctx.Error(http.StatusInternalServerError, "NewAccessToken", err) return } @@ -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(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(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 84fa129b13..f912296228 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 0000000000..7231e9add7 --- /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 fc74c8d148..33aa851a80 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 @@ -27,7 +27,7 @@ func ListEmails(ctx *context.APIContext) { // "200": // "$ref": "#/responses/EmailList" - emails, err := user_model.GetEmailAddresses(ctx.Doer.ID) + emails, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) if err != nil { ctx.Error(http.StatusInternalServerError, "GetEmailAddresses", err) return @@ -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(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(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 bc03b22ea7..6abb70de19 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" ) @@ -80,6 +81,8 @@ func ListFollowers(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" listUserFollowers(ctx, ctx.ContextUser) } @@ -142,12 +145,14 @@ func ListFollowing(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" listUserFollowing(ctx, ctx.ContextUser) } 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() @@ -217,9 +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) @@ -239,8 +252,10 @@ func Unfollow(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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 84327cc92a..5a2f995e1b 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) } @@ -63,6 +67,8 @@ func ListGPGKeys(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/GPGKeyList" + // "404": + // "$ref": "#/responses/notFound" listGPGKeys(ctx, ctx.ContextUser.ID, utils.GetListOptions(ctx)) } @@ -110,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() @@ -119,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) @@ -183,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 { @@ -196,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() @@ -205,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 @@ -257,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 4b642910b1..8b5c64e291 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 50be519c81..9d9ca5bf01 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 6c04d0943a..d9456e7ec6 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 { @@ -150,6 +153,8 @@ func ListPublicKeys(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/PublicKeyList" + // "404": + // "$ref": "#/responses/notFound" listPublicKeys(ctx, ctx.ContextUser) } @@ -174,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() @@ -194,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 @@ -259,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() @@ -275,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 86af8cb440..81f8e0f3fe 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, @@ -78,6 +78,8 @@ func ListUserRepos(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" private := ctx.IsSigned listUserRepos(ctx, ctx.ContextUser, private) @@ -160,6 +162,8 @@ func ListOrgRepos(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" listUserRepos(ctx, ctx.Org.Organization.AsUser(), ctx.IsSigned) } diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go new file mode 100644 index 0000000000..899218473e --- /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 53794c82f8..d0a8daaa85 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 9399ad2b4d..ad9ed9548d 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 } @@ -61,9 +64,11 @@ func GetStarredRepos(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$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 @@ -93,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) } @@ -150,10 +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.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) @@ -178,8 +191,10 @@ func Unstar(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" - err := repo_model.StarRepo(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 2a2361be67..09147cd2ae 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(&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 172d9d5cc5..2cc23ae476 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 } @@ -59,9 +62,11 @@ func GetWatchedRepos(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/RepositoryList" + // "404": + // "$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) } @@ -90,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) } @@ -122,7 +127,7 @@ func IsWatching(ctx *context.APIContext) { // "404": // description: User is not watching this repo or repo do not exist - if repo_model.IsWatching(ctx.Doer.ID, ctx.Repo.Repository.ID) { + if repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { ctx.JSON(http.StatusOK, api.WatchInfo{ Subscribed: true, Ignored: false, @@ -155,10 +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{ @@ -190,8 +203,10 @@ func Unwatch(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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 32f5c85319..4e25137817 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 b62d20a18a..f1abd49a7d 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 6910b82931..024ba7b8d9 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 new file mode 100644 index 0000000000..115d65ed10 --- /dev/null +++ b/routers/common/auth.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web/middleware" + auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" +) + +type AuthResult struct { + Doer *user_model.User + IsBasicAuth bool +} + +func AuthShared(ctx *context.Base, sessionStore auth_service.SessionStore, authMethod auth_service.Method) (ar AuthResult, err error) { + ar.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, sessionStore) + if err != nil { + return ar, err + } + if ar.Doer != nil { + if ctx.Locale.Language() != ar.Doer.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + ar.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth_service.BasicMethodName + + ctx.Data["IsSigned"] = true + ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer + ctx.Data["SignedUserID"] = ar.Doer.ID + ctx.Data["IsAdmin"] = ar.Doer.IsAdmin + } else { + ctx.Data["SignedUserID"] = int64(0) + } + return ar, nil +} + +// VerifyOptions contains required or check options +type VerifyOptions struct { + SignInRequired bool + SignOutRequired bool + AdminRequired bool + DisableCSRF bool +} diff --git a/routers/common/db.go b/routers/common/db.go index 2e86fbd0fd..a67c9582fa 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 9c8ccc3388..402ca44c12 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 ea9a9e745c..4fd63ba49e 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 5f412014d7..2d5638ef61 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 8a39dda179..c7c75fb099 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 9bf2025e19..34044e814b 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 8a7f8b3332..446908db75 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 020fff31c0..aaf95920c2 100644 --- a/routers/init.go +++ b/routers/init.go @@ -9,19 +9,14 @@ 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" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" - code_indexer "code.gitea.io/gitea/modules/indexer/code" - issue_indexer "code.gitea.io/gitea/modules/indexer/issues" - stats_indexer "code.gitea.io/gitea/modules/indexer/stats" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/external" - "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/ssh" "code.gitea.io/gitea/modules/storage" @@ -37,19 +32,24 @@ 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" "code.gitea.io/gitea/services/cron" + feed_service "code.gitea.io/gitea/services/feed" + indexer_service "code.gitea.io/gitea/services/indexer" "code.gitea.io/gitea/services/mailer" mailer_incoming "code.gitea.io/gitea/services/mailer/incoming" markup_service "code.gitea.io/gitea/services/markup" 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" + "code.gitea.io/gitea/services/uinotification" "code.gitea.io/gitea/services/webhook" ) @@ -73,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 } @@ -94,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 } @@ -119,9 +119,10 @@ func InitWebInstalled(ctx context.Context) { mustInit(storage.Init) mailer.NewContext(ctx) - mustInit(cache.NewContext) - notification.NewContext() - mustInit(archiver.Init) + mustInit(cache.Init) + mustInit(feed_service.Init) + mustInit(uinotification.Init) + mustInitCtx(ctx, archiver.Init) highlight.NewContext() external.RegisterRenderers() @@ -136,16 +137,16 @@ 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. - issue_indexer.InitIssueIndexer(false) - code_indexer.Init() - mustInit(stats_indexer.Init) + mustInit(indexer_service.Init) mirror_service.InitSyncMirrors() mustInit(webhook.Init) @@ -197,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 6d60dfdca3..9c6a8849b6 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,11 +539,11 @@ 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(u, overwriteDefault); err != nil { + if err = user_model.CreateUser(ctx, u, overwriteDefault); err != nil { if !user_model.IsErrUserAlreadyExist(err) { setting.InstallLock = false ctx.Data["Err_AdminName"] = true @@ -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 fcbd052977..2aa7f5d7b7 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 2403b9c41a..53c2412308 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 @@ -41,8 +41,8 @@ func GenerateActionsRunnerToken(ctx *context.PrivateContext) { }) } - 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) @@ -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 b15d6ba33a..33890be6a9 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), @@ -29,7 +30,7 @@ func SetDefaultBranch(ctx *gitea_context.PrivateContext) { } } - if err := repo_model.UpdateDefaultBranch(ctx.Repo.Repository); err != nil { + if err := repo_model.UpdateDefaultBranch(ctx, ctx.Repo.Repository); err != nil { 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 0c9a2a10fa..101ae92302 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -8,16 +8,19 @@ import ( "net/http" "strconv" + git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" 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" 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 +30,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 +38,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 +76,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 +87,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 { @@ -124,7 +194,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { // 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 git.DefaultFeatures.SupportProcReceive && refFullName.IsPull() { if repo == nil { repo = loadRepository(ctx, ownerName, repoName) if ctx.Written() { @@ -150,7 +220,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } results = append(results, private.HookPostReceiveBranchResult{ - Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(), + Message: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx), Create: false, Branch: "", URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index), @@ -159,8 +229,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } // 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 +248,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 +286,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 4399e49851..32ec3003e2 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 5577120770..cee3bbdd12 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 caf3874ec3..42b8e5abed 100644 --- a/routers/private/hook_verification.go +++ b/routers/private/hook_verification.go @@ -28,23 +28,32 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env [] _ = stdoutWriter.Close() }() + var command *git.Command + 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 + command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(newCommitID).AddArguments("--not", "--all") + } else { + command = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(oldCommitID + "..." + newCommitID) + } // This is safe as force pushes are already forbidden - err = git.NewCommand(repo.Ctx, "rev-list").AddDynamicArguments(oldCommitID + "..." + newCommitID). - Run(&git.RunOpts{ - Env: env, - Dir: repo.Path, - Stdout: stdoutWriter, - PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) - if err != nil { - log.Error("%v", err) - cancel() - } - _ = stdoutReader.Close() - return err - }, - }) + err = command.Run(&git.RunOpts{ + Env: env, + Dir: repo.Path, + Stdout: stdoutWriter, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + err := readAndVerifyCommitsFromShaReader(stdoutReader, repo, env) + if err != nil { + log.Error("%v", err) + cancel() + } + _ = stdoutReader.Close() + return err + }, + }) if err != nil && !isErrUnverifiedCommit(err) { log.Error("Unable to check commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) } @@ -74,7 +83,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{ @@ -83,7 +93,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 new file mode 100644 index 0000000000..04445b8eaf --- /dev/null +++ b/routers/private/hook_verification_test.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + + "github.com/stretchr/testify/assert" +) + +var testReposDir = "tests/repos/" + +func TestVerifyCommits(t *testing.T) { + unittest.PrepareTestEnv(t) + + gitRepo, err := git.OpenRepository(context.Background(), testReposDir+"repo1_hook_verification") + 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}, + {objectFormat.EmptyObjectID().String(), "93eac826f6188f34646cea81bf426aa5ba7d3bfe", true}, // New branch with verified commit + {"9779d17a04f1e2640583d35703c62460b2d86e0a", "72920278f2f999e3005801e5d5b8ab8139d3641c", false}, + {objectFormat.EmptyObjectID().String(), "9ce3f779ae33f31fce17fac3c512047b75d7498b", false}, // New branch with unverified commit + } + + for _, tc := range testCases { + err = verifyCommits(tc.base, tc.head, gitRepo, nil) + if tc.verified { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + } +} diff --git a/routers/private/internal.go b/routers/private/internal.go index 407edebeed..ede310113c 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 5e7e82b03c..e8ee8ba8ac 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 a13b4c12ae..5b8f238a83 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 e5e162c880..c19ee67896 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" ) diff --git a/routers/private/main_test.go b/routers/private/main_test.go new file mode 100644 index 0000000000..a6bec72b41 --- /dev/null +++ b/routers/private/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/private/manager.go b/routers/private/manager.go index 397e6fac7b..a6aa03e4ec 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 68e4a21805..9a0298a37c 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 09ced33b8d..0c63ebc918 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 bd3c3c30d0..f1b9365f52 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 7efc22a3d9..4e95d3071d 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 b1efc58800..85368a0aed 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 eacfa18f05..5bec632ead 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/private/tests/repos/repo1_hook_verification/HEAD b/routers/private/tests/repos/repo1_hook_verification/HEAD new file mode 100644 index 0000000000..b870d82622 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/routers/private/tests/repos/repo1_hook_verification/config b/routers/private/tests/repos/repo1_hook_verification/config new file mode 100644 index 0000000000..64280b806c --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = true + symlinks = false + ignorecase = true diff --git a/routers/private/tests/repos/repo1_hook_verification/info/refs b/routers/private/tests/repos/repo1_hook_verification/info/refs new file mode 100644 index 0000000000..ee593c4db2 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/info/refs @@ -0,0 +1 @@ +d766f2917716d45be24bfa968b8409544941be32 refs/heads/main diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/HEAD b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD new file mode 100644 index 0000000000..5c549b9b4e --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/logs/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea 1693148474 +0800 push diff --git a/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main new file mode 100644 index 0000000000..5c549b9b4e --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/logs/refs/heads/main @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 d766f2917716d45be24bfa968b8409544941be32 Gitea 1693148474 +0800 push diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c new file mode 100644 index 0000000000..d55278f9d4 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a new file mode 100644 index 0000000000..d8b6020bc0 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/0b/5987362fe3fabdd4406babdc819642ee2f5a2a differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e new file mode 100644 index 0000000000..77936d8241 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/13/b0f23f673b161f4b5cb66f051cb93c99729e1e differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 new file mode 100644 index 0000000000..5ec09ac7bd Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/23/33a51fdb238b7023a62ae3dcc58994061a7c86 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 new file mode 100644 index 0000000000..355b88e95e Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/2b/df04adb23d2b40b6085efb230856e5e2a775b7 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 new file mode 100644 index 0000000000..ba1f06fc0e Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/65/a457425a679cbe9adf0d2741785d3ceabb44a7 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c new file mode 100644 index 0000000000..4705fbf6b1 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/72/920278f2f999e3005801e5d5b8ab8139d3641c @@ -0,0 +1,2 @@ +xK +1] AS$"32 ooW{!`JC%. $r]sѱe$mM)(O`btlE[:;4H1_rayl~EL@cXvM":MۃG_}? \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd new file mode 100644 index 0000000000..fd3c3a488b Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/8b/903ede7c494725624bf842ec890f6342dababd differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe new file mode 100644 index 0000000000..80abd3ade1 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/93/eac826f6188f34646cea81bf426aa5ba7d3bfe differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a new file mode 100644 index 0000000000..7f3293a8bc --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/97/79d17a04f1e2640583d35703c62460b2d86e0a @@ -0,0 +1,2 @@ +x1 +!ES{AwGGa 9EQg W#AZ/p((Bhۼ&:pLY`U-z\ZM:xJ/G}:3 \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b new file mode 100644 index 0000000000..61a9ee8391 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/9c/e3f779ae33f31fce17fac3c512047b75d7498b differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 new file mode 100644 index 0000000000..fb79dc96b9 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/a9/f76e70a663e40091749a97eeac5f57a6fec141 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 new file mode 100644 index 0000000000..1801a7f127 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/ba/0caedd359ebe310ef431335576e20f2b84e9b9 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c new file mode 100644 index 0000000000..a765d66758 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/bb/87653e0819460e79b5f075f2563f583cbbf18c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa new file mode 100644 index 0000000000..c7de09f9ac --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa @@ -0,0 +1,3 @@ +xA +0E]$L xL2] + \}e[:{MZ5b8$v fR37];ˆbt 3$,tXG>m p1w͗-7pĄpZsDZL̾Le@ \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 new file mode 100644 index 0000000000..154458468c --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/d7/66f2917716d45be24bfa968b8409544941be32 @@ -0,0 +1,3 @@ +xˮFE3+zn%44!%QxۀsA` 8{2IMjdf2"$e +-( !J"a@BaHo3V<$/)$JJDBH{ # RROnfFO +q[2̇~zjjL}prmFqh `@ث՘f?3[7) ^uֿ,l7zr|&Ou49:Qj1x6Q%tsV| (V,aL,G~r@`$[! Xˊep[8 o(kZγyeйYkd63;3 Ri ދdYDk91V]/C#&poFb}uW&]+m xaqdIX3 3KI#i_rgĩ7=`@[&A̤Lo3~M8MGt>xvQ(aWo"srzeŭ}QD֨fK)mr>>̚$F8x ^J k{mczI*^Mb m6M~hp {0 ]€?nUwgɠJ б<72 \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 new file mode 100644 index 0000000000..b3f925e817 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e3/7e5d19823e42fad252f6341b1f77a7bc6ee451 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 new file mode 100644 index 0000000000..7112238943 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb new file mode 100644 index 0000000000..24580f88e3 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/e8/4e452c3a20c02dee17ec2f63c0cb9ef6c759eb differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/info/packs b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/info/packs @@ -0,0 +1 @@ + diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c new file mode 100644 index 0000000000..d55278f9d4 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/08/cbc8f0e903b0916025ae7577564b7ed39ecb2c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a new file mode 100644 index 0000000000..d8b6020bc0 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/0b/5987362fe3fabdd4406babdc819642ee2f5a2a differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e new file mode 100644 index 0000000000..77936d8241 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/13/b0f23f673b161f4b5cb66f051cb93c99729e1e differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 new file mode 100644 index 0000000000..5ec09ac7bd Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/23/33a51fdb238b7023a62ae3dcc58994061a7c86 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd new file mode 100644 index 0000000000..fd3c3a488b Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/8b/903ede7c494725624bf842ec890f6342dababd differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe new file mode 100644 index 0000000000..80abd3ade1 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/93/eac826f6188f34646cea81bf426aa5ba7d3bfe differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b new file mode 100644 index 0000000000..61a9ee8391 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/9c/e3f779ae33f31fce17fac3c512047b75d7498b differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 new file mode 100644 index 0000000000..fb79dc96b9 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/a9/f76e70a663e40091749a97eeac5f57a6fec141 differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c new file mode 100644 index 0000000000..a765d66758 Binary files /dev/null and b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/bb/87653e0819460e79b5f075f2563f583cbbf18c differ diff --git a/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa new file mode 100644 index 0000000000..c7de09f9ac --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/objects/tmp_objdir-incoming-a21648/cb/a4c30c196a0e03e7bdf6eeb8393d14b9d073aa @@ -0,0 +1,3 @@ +xA +0E]$L xL2] + \}e[:{MZ5b8$v fR37];ˆbt 3$,tXG>m p1w͗-7pĄpZsDZL̾Le@ \ No newline at end of file diff --git a/routers/private/tests/repos/repo1_hook_verification/refs/heads/main b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main new file mode 100644 index 0000000000..2186e820a7 --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification/refs/heads/main @@ -0,0 +1 @@ +d766f2917716d45be24bfa968b8409544941be32 diff --git a/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt new file mode 100644 index 0000000000..3061c75dcb --- /dev/null +++ b/routers/private/tests/repos/repo1_hook_verification_dummy_gpg_key.txt @@ -0,0 +1,127 @@ +# GPG key for abcde@gitea.com + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBGTrY3UBDAC2HLBqmMplAV15qSnC7g1c4dV406f5EHNhFr95Nup2My6b2eaf +Tlvedv77s8PT/I7F3fy4apOZs5A7w2SsPlLMcQ3ev4uGOsxRtkq5RLy1Yb6SNueX +0Da2UVKR5KTC5Q6BWaqxwS0IjKOLZ/xz0Pbe/ClV3bZSKBEY2omkVo3Z0HZ771vB +2clPRvGJ/IdeKOsZ3ZytSFXfyiJBdARmeSPmydXLil8+Ibq5iLAeow5PK8hK1TCO +nKHzLWNqcNq70tyjoHvcGi70iGjoVEEUgPCLLuU8WmzTJwlvA3BuDzjtaO7TLo/j +dE6iqkHtMSS8x+43sAH6hcFRCWAVh/0Uq7n36uGDfNxGnX3YrmX3LR9x5IsBES1r +GGWbpxio4o5GIf/Xd+JgDd9rzJCqRuZ3/sW/TxK38htWaVNZV0kMkHUCTc1ctzWp +Cm635hbFCHBhPYIp+/z206khkAKDbz/CNuU91Wazsh7KO07wrwDtxfDDbInJ8TfH +E2TGjzjQzgChfmcAEQEAAbQXYWJjZGUgPGFiY2RlQGdpdGVhLmNvbT6JAc4EEwEI +ADgWIQRo/BkcvP70fnQCv16xVDFkJim4JgUCZOtjdQIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgAAKCRCxVDFkJim4Js6+C/9yIjHqcyM88hQAYQUoiPYfgJ0f2NsD +Ai/XypyDaFbRy9Wqm3oKvMr9L9G5xgOXshjRaRWOpODAwLmtVrJfOV5BhxLEcBcO +2hDdM3ycp8Gt7+Fx/o0cUjPiiC18hh3K5LRfeE7oYynSJDgjoDNuzIMuyoWuJPNc ++IcE4roND55qyyyC9ObrTLz1GgGm1bXtkHhZ1NdOfQ4q8M48K39Jn7pmnmSX3R74 +CSU6flh/o9AtzGLjU70JUOLFcWnR5D0iEI8mOsdfEHr+p+CvDVG9l4unPhMunT+Q +OUwV2DEmqo9P+yIert1ucVTDoSf+FrRaKUHg8r1Tt6T4/4GyIeSxG72NImK0h8jz ++bADPZhxuG4UR1Mj8bilqhWgODFPi/5DrDsNMWq1pEvjn6f4pCUx0IDTnPTniOXt +afXtAD4Rz0rwJWYqgeJFHgjXzaxBiOE1bhS26NPEvyAa0T9Tj3E73ICMESAmVad2 +JqO/mVxkLDGWdpXM7qB8bO2YGMOplrTvWaa5AY0EZOtjdQEMAOwevO46JxBo91RC +bT7RQ2uz3ZwRKb+P/jIEFST6x8tkCjon31zh6HicBDPNntqXTzStgoHQb7vGhHPV +4dxAfrOtVyoHwpi1/+x1jjtZoyIzLEz6RNK/Onu2y/tC5JBnSd5QRdHJgzPm20F8 +iNZR37c0Mi24fIH4y01aVLfNeBpRt7lWJ+opo2bM3Rh7jJdMpynKkTcA6o9XP6Ig +W/dzpOayosclpHhWiJwKV4CovIX/bxawk7sz10Nb4QzcxlWexWnJxNRHIcAkZ9KT +XTBpBkBpHCZqsI3+rQoQn5oQAr9JGWJSd4Fmgw7mFjmIF4bjfa2h/BpCoBqE+/25 +chvWfYkQwrCcyUwD1QYPUBwNvLB+PWb9kYEHD3mLgSSR+fjdG9XdMevu4lT91Gqo +/6KJzgzClSs7GoQtb+SZ4deUFw1tlmEQS/BGhbtTb/1566iDidGV5EnSmL/E4/3C +bGQqNog8gremF0G0SlWTjD9RMBY13IgisWCC6R4CdkXIYnCWbwARAQABiQG2BBgB +CAAgFiEEaPwZHLz+9H50Ar9esVQxZCYpuCYFAmTrY3UCGwwACgkQsVQxZCYpuCb1 +AAv/dI5YtGxBXaHAMj+lOLmZi5w4t0M7Zafa8tNnWrBwj4KixiXEt52i5YKxuaVD +3+/cMqidSDp0M5Cxx0wcmnmg+mdFFcowtXIXuk1TGTcHcOCPoXgF6gfoGimNNE1A +w1+EnC4/TbjMCKEM7b2QZ7/CgkBxZJWbScN4Jtawory9LEQqo0/epYJwf+79GHIJ +rpODAPiPJEMKmlej23KyoFuusOi17C0vHCf3GZNj4F2So3LOrcs51qTlOum2MdL5 +oTdqffatzs6p4u5bHBxyRugQlQggTRSK+TXLdxnFXr9ukXjIC2mFir7CCnZHw4e+ +2JwZfaAom0ZX+pLwrReSop4BPPU2YDzt3XCUk0S9kpiOsN7iFWUMCFreIE50DOxt +9406kSGopYKVaifbDl4MdLXM4v+oucLe7/yOViT/dm4FcIytIR+jzC8MaLQTB23e +uzm2wOjI1YOwv7Il6PWZyDdU+tyzXcaJ7wSFBeQFZZtqph2TItCeV04HoaKHHc25 +4akc +=OYIo +-----END PGP PUBLIC KEY BLOCK----- + +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQWGBGTrY3UBDAC2HLBqmMplAV15qSnC7g1c4dV406f5EHNhFr95Nup2My6b2eaf +Tlvedv77s8PT/I7F3fy4apOZs5A7w2SsPlLMcQ3ev4uGOsxRtkq5RLy1Yb6SNueX +0Da2UVKR5KTC5Q6BWaqxwS0IjKOLZ/xz0Pbe/ClV3bZSKBEY2omkVo3Z0HZ771vB +2clPRvGJ/IdeKOsZ3ZytSFXfyiJBdARmeSPmydXLil8+Ibq5iLAeow5PK8hK1TCO +nKHzLWNqcNq70tyjoHvcGi70iGjoVEEUgPCLLuU8WmzTJwlvA3BuDzjtaO7TLo/j +dE6iqkHtMSS8x+43sAH6hcFRCWAVh/0Uq7n36uGDfNxGnX3YrmX3LR9x5IsBES1r +GGWbpxio4o5GIf/Xd+JgDd9rzJCqRuZ3/sW/TxK38htWaVNZV0kMkHUCTc1ctzWp +Cm635hbFCHBhPYIp+/z206khkAKDbz/CNuU91Wazsh7KO07wrwDtxfDDbInJ8TfH +E2TGjzjQzgChfmcAEQEAAf4HAwKN54iG/XBl5/UViAmmiESRj3u+uJC9EztalVbj +156bjamUHBYIoCH4SBB0l0bR/o9ZN3vE4ZvyF3OyJ0AKF9epjWIuz7S+QIm1NLzk +IqwRyfGPsktwtZOF1CsathN4RyJL5/3nB9g4BLYfRARe9lwU0C0HQjBwAVj8m6RN ++wMTHZqW7tUN75npgPRLUI30H3GPVm3yLfS88Ol8nd31r7V0JsXZ2/mM9CWF4sUy +o1DW3P/rBn49s/x2qL/acEL+5PK7suFBP8Pjp5cwGjnSehoWeOclXgstkg3OEryY +2JP74muDVmaEVOAk7wiRjUD7HYuEOm/MbphFyen7QtO8WtN3IRKgNm19v5Skd4AF +NW9ZAdQOk2yHw7zyRk7HOPmEbEstbyE1RYWIfgZGjJlEJ2DI5ABwVJJ3W6DRPiZ3 +owd/JxBUVu/wigIjbg6z6ZQd/bn1XwKyhyTtgyTyILzE1gqtO7xs1XmK3wcww794 +cVLjqSnAdaeXMt4P+sDA17Wqky0f/jQ9kq7/tv7ipq9jvp9RaQ1ccRsz+mGgBVl+ +oLg4klKN47ZQGt0SQpLzHLL8SHzY0dz5US+Z2J+hdZia6jEmfilY9r4WPe7djMYz +Na908DmcbjfAg4XHPqVRXjgraUiT2YTo2LOV2dHn7550hJ/JshpOVqrJUrjhCgDN +usEMK3KXJkFvf6zflMv3t8HMD2SGBfpCJSwDaW+mrmtpR6a5laoZxg/009qZqgpj +FuenLuZmgYrHXozMXllwi6MLvSE/ioXrK4fqvpAwzOk6ArqZdWfxoJDYNQKXVL7z +Arniq9Ctaag8hr5T+JoZ9wNPNVF/LuEwPTWDur4qpU07KqWt9OFKPsEDNzxVZfNM +vtSCYvQ1uUH3CbPLQvPpd5TnyhjwKYtTzyW4OcuZHrWIZp9fZi5QdhWxobqGQiBk ++nRNFe0FPVEN0VcNdYJIDKcDLsOYCkGy08tucZnbKtr8JaK7XBSOo9Frg1i/j4Aa +GnXWlkMTVAkuxLZPATTOgdBoYmHMYKQvw31aFBrf3QU9c3EEg9UPYFMErVIeBHBB +BS+E7QZToHScCG1zezlr4rdqarkz0Yvzc3aduoSAOJHDf/Il+tOkepMne1y5fi72 +5UT1yWGbXXkTCV/pM6s0pLaEvNHmGvPQ6VGbJ//5w+42PFD1d7yEai53OgSZNs7B ++Ie/6Vq5GYzTM0bT3/o7/O1Zi56y791YKaas9wgxOhmMIZ0hsTecQJLJZGotUlOv +V7fZUhPRc4ksUeCyM3G0E89ilFtY6NuPcWQ8yMeS4sRRLmie+iaT+kNvAqL5mXvg +WNLhFIXPC1gpGLB8lpT5YEY647aPjQEig7QXYWJjZGUgPGFiY2RlQGdpdGVhLmNv +bT6JAc4EEwEIADgWIQRo/BkcvP70fnQCv16xVDFkJim4JgUCZOtjdQIbAwULCQgH +AgYVCgkICwIEFgIDAQIeAQIXgAAKCRCxVDFkJim4Js6+C/9yIjHqcyM88hQAYQUo +iPYfgJ0f2NsDAi/XypyDaFbRy9Wqm3oKvMr9L9G5xgOXshjRaRWOpODAwLmtVrJf +OV5BhxLEcBcO2hDdM3ycp8Gt7+Fx/o0cUjPiiC18hh3K5LRfeE7oYynSJDgjoDNu +zIMuyoWuJPNc+IcE4roND55qyyyC9ObrTLz1GgGm1bXtkHhZ1NdOfQ4q8M48K39J +n7pmnmSX3R74CSU6flh/o9AtzGLjU70JUOLFcWnR5D0iEI8mOsdfEHr+p+CvDVG9 +l4unPhMunT+QOUwV2DEmqo9P+yIert1ucVTDoSf+FrRaKUHg8r1Tt6T4/4GyIeSx +G72NImK0h8jz+bADPZhxuG4UR1Mj8bilqhWgODFPi/5DrDsNMWq1pEvjn6f4pCUx +0IDTnPTniOXtafXtAD4Rz0rwJWYqgeJFHgjXzaxBiOE1bhS26NPEvyAa0T9Tj3E7 +3ICMESAmVad2JqO/mVxkLDGWdpXM7qB8bO2YGMOplrTvWaadBYYEZOtjdQEMAOwe +vO46JxBo91RCbT7RQ2uz3ZwRKb+P/jIEFST6x8tkCjon31zh6HicBDPNntqXTzSt +goHQb7vGhHPV4dxAfrOtVyoHwpi1/+x1jjtZoyIzLEz6RNK/Onu2y/tC5JBnSd5Q +RdHJgzPm20F8iNZR37c0Mi24fIH4y01aVLfNeBpRt7lWJ+opo2bM3Rh7jJdMpynK +kTcA6o9XP6IgW/dzpOayosclpHhWiJwKV4CovIX/bxawk7sz10Nb4QzcxlWexWnJ +xNRHIcAkZ9KTXTBpBkBpHCZqsI3+rQoQn5oQAr9JGWJSd4Fmgw7mFjmIF4bjfa2h +/BpCoBqE+/25chvWfYkQwrCcyUwD1QYPUBwNvLB+PWb9kYEHD3mLgSSR+fjdG9Xd +Mevu4lT91Gqo/6KJzgzClSs7GoQtb+SZ4deUFw1tlmEQS/BGhbtTb/1566iDidGV +5EnSmL/E4/3CbGQqNog8gremF0G0SlWTjD9RMBY13IgisWCC6R4CdkXIYnCWbwAR +AQAB/gcDAgtreHsdznsa9bAha2g+J5zygs7rp95KvqRm4SGrgWPnngMewrHXrJAx +REUQFbOYJKvb6+SB47N8BTIh/nEY/B6dpvC36QSHB0XAgkktiOhdS2rTlrq+bKse +rZzoM/jbcxS3/cwi4VWH4lQhz7TLZtQxFZDuwyiik8/m5KscMxQrbYJg++4KpFQQ +En7RRUO0hEaYdnqQ9t3M8SWLwZn2yK3hzBE0gkQ8CJA3Zokv3DO7FSsAX823O25B +X7NgIpmbHCeYK6YV0gjQUKP1o3Sf7DhJzO1iltg0+obNTDl9RoeFgxTVORCdUlGA +kPdgoBbAGtadpZlCMThn7FlIn+ogqwQpAcoSTZjX31SOQBBpgMW9yf3GTNk2Nvrn +08zIA0hnUWFfc4VY6fbjbX5bF0jpoJ3XG6Hwa1VVRwQGFLxFV23TbZ+baLLuxEBx +A86XDC5zWFMwF/7aYL8oeXgoI+499u9G4Gw9G87va7rQXlTQJcHQRqu9YaGcxwOi +UslhNtVWz52iIURappUfFaGBRGUvtx2DOTgn4m099nnPaKDUiLmc4bFIHwzyA7Pl +RdAmLosrxSyIxHdlUOS/KshucXXKGVoYkJqGLXNQCY6x2zbyBPX9/a/0P59UP/WU +qwAHuGbXlToGhSKZzC8KmVs12tyQsAZ/47D+G29kEcRlaey1+N3Uor1jN7D66uyj +M1jYFhBudNIuuTR8sfrYjmbYIj8y0bgvF4RN6sU1padoTETadWNyIcFiRMZQ0oQd +KJBa3CxdqQZ2EU4a5jkA4UTQE13IySh7eNbYP5VwBgr3Z59gcbouKfFxKBhmPHF2 +BAmC0VXI2BgqKNqM6QgVj5UKrp41AX4D+iIhyKa0D3rapuIywXg1AtsrAlrOU/Ig +tQCj/a0NjIVJpLqVKBUdd4Eea69fDCJGIoaDNyp7qwo+nA1O2oDbc32EryJYUkHm +XMoLmx5y+/rxRsRevBv0ojwu3zsx2K93M1wHYd0z+SJsU8QGFinoFgYcmNp/tgMW +WtHBN4AijDuDSZAyG+MrWIj3NS4mbajx+utEIn3DC/ofFPlTmgX3OvpOPG1hnhBH +xSZUME+znOnqJMpUqnna4jbHEPwvRIXUY6InFKgl1Bu4grww/oo3qi7NwWL0Mcdy +qabWhdlEz5N/QBBPWVQllelgI+xTmZoCRUhh1mn+PM900vXXeM/DIALnxEXs9I/m +l4wPdLZlCdaKZS8vv33adyS6i9gWfI3NPWxZ2TyqC7nf5D5OK1zKSu3iWx17nXn2 +ak5hZnaXfzTxuZL3E8KZD/qsDm80c2PXFitogJTih37N6A8UQOJPtWbkfvPiwUvI +gw0oouggn0iJQVNoiQG2BBgBCAAgFiEEaPwZHLz+9H50Ar9esVQxZCYpuCYFAmTr +Y3UCGwwACgkQsVQxZCYpuCb1AAv/dI5YtGxBXaHAMj+lOLmZi5w4t0M7Zafa8tNn +WrBwj4KixiXEt52i5YKxuaVD3+/cMqidSDp0M5Cxx0wcmnmg+mdFFcowtXIXuk1T +GTcHcOCPoXgF6gfoGimNNE1Aw1+EnC4/TbjMCKEM7b2QZ7/CgkBxZJWbScN4Jtaw +ory9LEQqo0/epYJwf+79GHIJrpODAPiPJEMKmlej23KyoFuusOi17C0vHCf3GZNj +4F2So3LOrcs51qTlOum2MdL5oTdqffatzs6p4u5bHBxyRugQlQggTRSK+TXLdxnF +Xr9ukXjIC2mFir7CCnZHw4e+2JwZfaAom0ZX+pLwrReSop4BPPU2YDzt3XCUk0S9 +kpiOsN7iFWUMCFreIE50DOxt9406kSGopYKVaifbDl4MdLXM4v+oucLe7/yOViT/ +dm4FcIytIR+jzC8MaLQTB23euzm2wOjI1YOwv7Il6PWZyDdU+tyzXcaJ7wSFBeQF +ZZtqph2TItCeV04HoaKHHc254akc +=PPG4 +-----END PGP PRIVATE KEY BLOCK----- diff --git a/routers/utils/utils.go b/routers/utils/utils.go index d6856fceac..3035073d5c 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 6d19214c88..6e7f3c33cd 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 fe3e1d2206..4dc0dfdef8 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,7 +111,7 @@ 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 @@ -127,9 +131,8 @@ 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 @@ -137,6 +140,12 @@ func Dashboard(ctx *context.Context) { 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["DeprecatedWarnings"] = setting.DeprecatedWarnings + if len(setting.DeprecatedWarnings) == 0 && !setting.IsProd { + if time.Now().Unix()%2 == 0 { + ctx.Data["DeprecatedWarnings"] = []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 @@ -182,7 +233,7 @@ func CronTasks(ctx *context.Context) { func MonitorStats(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.monitor.stats") ctx.Data["PageIsAdminMonitorStats"] = true - bs, err := json.Marshal(activities_model.GetStatistic().Counter) + bs, err := json.Marshal(activities_model.GetStatistic(ctx).Counter) if err != nil { ctx.ServerError("MonitorStats", err) return diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index b26912db48..8583398074 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 b743d1b0a5..ba487d1045 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 c70a2d1c95..2f5f17e201 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() - + config.GetDynGetter().InvalidateCache() prepareDeprecatedWarningsAlert(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), - }) - return + marshalBool := func(v string) (string, error) { + if b, _ := strconv.ParseBool(v); b { + return "true", nil } + return "false", 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), - }) + 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), + }) + } + b, err := json.Marshal(openWithEditorApps) + if err != nil { + return "", err + } + return string(b), nil + } + 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 5637894e6d..020554a35a 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 4618a78c3a..2cf4035c6a 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,14 +68,14 @@ 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) { - baseEmails, count, err = user_model.SearchEmails(opts) + baseEmails, count, err = user_model.SearchEmails(ctx, opts) if err != nil { ctx.ServerError("SearchEmails", err) return @@ -121,7 +121,7 @@ func ActivateEmail(ctx *context.Context) { log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate) - if err := user_model.ActivateUserEmail(uid, email, activate); err != nil { + if err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil { log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err) if user_model.IsErrEmailAlreadyUsed(err) { ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index cd8cc29cdf..8d59fbb858 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 ea79830fa1..e1294ddbb4 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 9e4588dd75..36303cbc06 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 d0fd0d5002..c5454db71e 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,12 +24,13 @@ 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{ - Actor: ctx.Doer, - Type: user_model.UserTypeOrganization, + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + IncludeReserved: true, // administrator needs to list all acounts include reserved ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.OrgPagingNum, }, diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go index 8d4c29813e..39f064a1be 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 18a8d7d3e6..d8c50730b1 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 d1d0abca02..0815879bb3 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,12 +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" - repo_module "code.gitea.io/gitea/modules/repository" "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" ) @@ -85,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 @@ -99,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) } @@ -144,7 +144,7 @@ func AdoptOrDeleteRepository(ctx *context.Context) { if has || !isDir { // Fallthrough to failure mode } else if action == "adopt" { - if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_module.CreateRepoOptions{ + if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ Name: dirSplit[1], IsPrivate: true, }); err != nil { diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go index eaa268b4f1..d73290a8db 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 b603fb59a2..d6def94bb4 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 e560a88b4c..b93668c5a2 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" @@ -13,17 +14,19 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" - system_model "code.gitea.io/gitea/models/system" + org_model "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/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" @@ -32,9 +35,13 @@ import ( const ( tplUsers base.TplName = "admin/user/list" tplUserNew base.TplName = "admin/user/new" + tplUserView base.TplName = "admin/user/view" 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") @@ -54,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{ @@ -74,6 +81,7 @@ func Users(ctx *context.Context) { IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]), IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]), IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]), + IncludeReserved: true, // administrator needs to list all acounts include reserved, bot, remote ones ExtraParamStrings: extraParamStrings, }, tplUsers) } @@ -87,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 @@ -106,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 @@ -128,7 +140,7 @@ func NewUserPost(ctx *context.Context) { } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), Visibility: &form.Visibility, } @@ -152,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") } @@ -166,7 +177,7 @@ func NewUserPost(ctx *context.Context) { u.MustChangePassword = form.MustChangePassword } - if err := user_model.CreateUser(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 @@ -174,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): @@ -194,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. @@ -218,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 @@ -227,19 +240,19 @@ 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 } ctx.Data["Sources"] = sources - hasTOTP, err := auth.HasTwoFactorByUID(u.ID) + hasTOTP, err := auth.HasTwoFactorByUID(ctx, u.ID) if err != nil { 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 @@ -249,15 +262,69 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { return u } -// EditUser show editing user page -func EditUser(ctx *context.Context) { +func ViewUser(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.users.details") + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation + ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations + ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + + u := prepareUserInfo(ctx) + if ctx.Written() { + return + } + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db.ListOptionsAll, + OwnerID: u.ID, + OrderBy: db.SearchOrderByAlphabetically, + Private: true, + Collaborate: optional.Some(false), + }) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["ReposTotal"] = int(count) + + emails, err := user_model.GetEmailAddresses(ctx, u.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + ctx.Data["Emails"] = emails + ctx.Data["EmailsTotal"] = len(emails) + + orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ + ListOptions: db.ListOptionsAll, + UserID: u.ID, + IncludePrivate: true, + }) + if err != nil { + ctx.ServerError("FindOrgs", err) + return + } + + ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template + ctx.Data["OrgsTotal"] = len(orgs) + + ctx.HTML(http.StatusOK, tplUserView) +} + +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) + 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 @@ -268,142 +335,120 @@ 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) - + editUserCommon(ctx) u := prepareUserInfo(ctx) if ctx.Written() { return } + form := web.GetForm(ctx).(*forms.AdminEditUserForm) if ctx.HasError() { ctx.HTML(http.StatusOK, tplUserEdit) return } - 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) - } - } - - if len(form.Password) > 0 && (u.IsLocal() || u.IsOAuth2()) { - var err error - if len(form.Password) < setting.MinPasswordLength { - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserEdit, &form) - return - } - if !password.IsComplexEnough(form.Password) { - ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) - return - } - 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 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) } - 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 - } - - if u.Salt, err = user_model.GetUserSalt(); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - if err = u.SetPassword(form.Password); err != nil { - ctx.ServerError("SetPassword", err) return } } - 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 - } - u.Name = form.UserName - u.LowerName = strings.ToLower(form.UserName) + authOpts := &user_service.UpdateAuthOptions{ + Password: optional.FromNonDefault(form.Password), + LoginName: optional.Some(form.LoginName), } - if form.Reset2FA { - tf, err := auth.GetTwoFactorByUID(u.ID) - if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("auth.GetTwoFactorByUID", err) - return - } else if tf != nil { - if err := auth.DeleteTwoFactorByID(tf.ID, u.ID); err != nil { - ctx.ServerError("auth.DeleteTwoFactorByID", err) - return - } - } - - wn, err := auth.GetWebAuthnCredentialsByUID(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 { - 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 + authOpts.ProhibitLogin = optional.Some(false) } else { - u.ProhibitLogin = form.ProhibitLogin + authOpts.ProhibitLogin = optional.Some(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) + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + authSource, _ := strconv.ParseInt(fields[1], 10, 64) + + authOpts.LoginSource = optional.Some(authSource) + } + + 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) + case errors.Is(err, password.ErrComplexity): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplUserEdit, &form) + case errors.Is(err, password.ErrIsPwned): + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned"), tplUserEdit, &form) + case password.IsErrIsPwnedRequest(err): + log.Error("%s", err.Error()) + ctx.Data["Err_Password"] = true + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + default: + ctx.ServerError("UpdateUser", err) + } + return + } + + 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 !user_model.IsEmailDomainAllowed(form.Email) { + ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", form.Email)) + } + } + + 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) } @@ -411,6 +456,31 @@ func EditUserPost(ctx *context.Context) { } log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) + if form.Reset2FA { + tf, err := auth.GetTwoFactorByUID(ctx, u.ID) + if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } else if tf != nil { + if err := auth.DeleteTwoFactorByID(ctx, tf.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteTwoFactorByID", err) + return + } + } + + wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("auth.GetTwoFactorByUID", err) + return + } + for _, cred := range wn { + if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil { + ctx.ServerError("auth.DeleteCredential", err) + return + } + } + } + ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid"))) } @@ -440,7 +510,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) } @@ -476,7 +549,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 19d6d7294d..f6f9237858 100644 --- a/routers/web/admin/users_test.go +++ b/routers/web/admin/users_test.go @@ -10,8 +10,8 @@ import ( 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/test" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" @@ -19,7 +19,7 @@ import ( func TestNewUserPost_MustChangePassword(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "admin/users/new") + ctx, _ := contexttest.MockContext(t, "admin/users/new") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ IsAdmin: true, @@ -56,7 +56,7 @@ func TestNewUserPost_MustChangePassword(t *testing.T) { func TestNewUserPost_MustChangePasswordFalse(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "admin/users/new") + ctx, _ := contexttest.MockContext(t, "admin/users/new") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ IsAdmin: true, @@ -93,7 +93,7 @@ func TestNewUserPost_MustChangePasswordFalse(t *testing.T) { func TestNewUserPost_InvalidEmail(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "admin/users/new") + ctx, _ := contexttest.MockContext(t, "admin/users/new") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ IsAdmin: true, @@ -123,7 +123,7 @@ func TestNewUserPost_InvalidEmail(t *testing.T) { func TestNewUserPost_VisibilityDefaultPublic(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "admin/users/new") + ctx, _ := contexttest.MockContext(t, "admin/users/new") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ IsAdmin: true, @@ -161,7 +161,7 @@ func TestNewUserPost_VisibilityDefaultPublic(t *testing.T) { func TestNewUserPost_VisibilityPrivate(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "admin/users/new") + ctx, _ := contexttest.MockContext(t, "admin/users/new") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{ IsAdmin: true, diff --git a/routers/web/auth.go b/routers/web/auth.go deleted file mode 100644 index 1ca860ecc8..0000000000 --- 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 4791b04313..f93177bf96 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 } @@ -53,7 +52,7 @@ func TwoFactorPost(ctx *context.Context) { } id := idSess.(int64) - twofa, err := auth.GetTwoFactorByUID(id) + twofa, err := auth.GetTwoFactorByUID(ctx, id) if err != nil { ctx.ServerError("UserSignIn", err) 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 @@ -83,7 +82,7 @@ func TwoFactorPost(ctx *context.Context) { } twofa.LastUsedPasscode = form.Passcode - if err = auth.UpdateTwoFactor(twofa); err != nil { + if err = auth.UpdateTwoFactor(ctx, twofa); 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 } @@ -126,7 +124,7 @@ func TwoFactorScratchPost(ctx *context.Context) { } id := idSess.(int64) - twofa, err := auth.GetTwoFactorByUID(id) + twofa, err := auth.GetTwoFactorByUID(ctx, id) if err != nil { ctx.ServerError("UserSignIn", err) return @@ -140,7 +138,7 @@ func TwoFactorScratchPost(ctx *context.Context) { ctx.ServerError("UserSignIn", err) return } - if err = auth.UpdateTwoFactor(twofa); err != nil { + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { ctx.ServerError("UserSignIn", err) return } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3bf133f562..8b5cd986b8 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,68 +16,75 @@ 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 { + switch err { + case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired: + return false, nil + } + return false, err + } + if t == nil { + return false, nil + } + + u, err := user_model.GetUserByID(ctx, t.UserID) if err != nil { if !user_model.IsErrUserNotExist(err) { - return false, fmt.Errorf("GetUserByName: %w", err) + return false, fmt.Errorf("GetUserByID: %w", err) } return false, nil } - if val, ok := ctx.GetSuperSecureCookie( - base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { - return false, nil + isSucceed = true + + nt, token, err := auth_service.RegenerateAuthToken(ctx, t) + if err != nil { + return false, err } - isSucceed = true + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) if err := updateSession(ctx, nil, map[string]any{ // Set session IDs @@ -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) @@ -199,7 +224,7 @@ func SignInPost(ctx *context.Context) { } } - u, source, err := auth_service.UserSignIn(form.UserName, form.Password) + u, source, err := auth_service.UserSignIn(ctx, form.UserName, form.Password) if err != nil { if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) { ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) @@ -236,14 +261,14 @@ func SignInPost(ctx *context.Context) { // If this user is enrolled in 2FA TOTP, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. - hasTOTPtwofa, err := auth.HasTwoFactorByUID(u.ID) + hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID) if err != nil { ctx.ServerError("UserSignIn", err) return } // 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,12 +420,25 @@ func SignUp(ctx *context.Context) { ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers context.SetCaptchaData(ctx) + ctx.Data["PageIsSignUp"] = true // Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + ctx.HTML(http.StatusOK, tplSignUp) } @@ -408,7 +449,15 @@ func SignUpPost(ctx *context.Context) { ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) + if err != nil { + ctx.ServerError("UserSignUp", err) + return + } + + ctx.Data["OAuth2Providers"] = oauth2Providers context.SetCaptchaData(ctx) + ctx.Data["PageIsSignUp"] = true // Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true @@ -447,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") } @@ -486,15 +534,15 @@ func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form any // createUserInContext creates a user and handles errors within a given context. // Optionally a template can be specified. func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { - if err := user_model.CreateUser(u, overwrites); err != nil { + if err := user_model.CreateUser(ctx, u, overwrites); err != nil { if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { var user *user_model.User user = &user_model.User{Name: u.Name} - hasUser, err := user_model.GetUser(user) + hasUser, err := user_model.GetUser(ctx, user) if !hasUser || err != nil { user = &user_model.User{Email: u.Email} - hasUser, err = user_model.GetUser(user) + hasUser, err = user_model.GetUser(ctx, user) if !hasUser || err != nil { ctx.ServerError("UserLinkAccount", err) return false @@ -553,11 +601,13 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us // sends a confirmation email if required. 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(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 { + if user_model.CountUsers(ctx, nil) == 1 { + 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 } @@ -565,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) - - ctx.Data["IsSendRegisterMail"] = true - ctx.Data["Email"] = u.Email - ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) - ctx.HTML(http.StatusOK, TplActivate) - - 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 setting.Service.RegisterManualConfirm { + renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only")) return false } - return true + sendActivateEmail(ctx, u) + 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 + } + + 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 } - user := user_model.VerifyUserActiveCode(code) - // if code is wrong - if user == nil { - ctx.Data["IsCodeInvalid"] = true - ctx.HTML(http.StatusOK, TplActivate) + // 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 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 } @@ -651,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 } - user := user_model.VerifyUserActiveCode(code) - // if code is wrong - if user == nil { - ctx.Data["IsCodeInvalid"] = true - ctx.HTML(http.StatusOK, TplActivate) + // 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 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 } } @@ -699,7 +778,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { return } - if err := user_model.ActivateUserEmail(user.ID, user.Email, true); err != nil { + if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil { log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err) ctx.ServerError("ActivateUserEmail", err) return @@ -721,14 +800,18 @@ 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.RedirectToCurrentSite(redirectTo) + return + } + ctx.Redirect(setting.AppSubURL + "/") } @@ -738,8 +821,8 @@ func ActivateEmail(ctx *context.Context) { emailStr := ctx.FormString("email") // Verify code. - if email := user_model.VerifyActiveEmailCode(code, emailStr); email != nil { - if err := user_model.ActivateEmail(email); err != nil { + if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil { + if err := user_model.ActivateEmail(ctx, email); err != nil { ctx.ServerError("ActivateEmail", err) } @@ -748,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 0000000000..c6afbf877c --- /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 0f7ecf1af4..f744a57a43 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 @@ -142,7 +146,7 @@ func LinkAccountPostSignIn(ctx *context.Context) { return } - u, _, err := auth_service.UserSignIn(signInForm.UserName, signInForm.Password) + u, _, err := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password) if err != nil { handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err) return @@ -152,19 +156,19 @@ 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. // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here - _, err := auth.GetTwoFactorByUID(u.ID) + _, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil { if !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserLinkAccount", err) 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 8295515ba9..b438e5d518 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 78dc84472a..3189d1372e 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 @@ -859,7 +853,7 @@ func SignInOAuth(ctx *context.Context) { } // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user - user, gothUser, err := oAuth2UserLoginCallback(authSource, ctx.Req, ctx.Resp) + user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) if err == nil && user != nil { // we got the user without going through the whole OAuth2 authentication flow again handleOAuth2SignIn(ctx, authSource, user, gothUser) @@ -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 @@ -909,7 +903,7 @@ func SignInOAuthCallback(ctx *context.Context) { return } - u, gothUser, err := oAuth2UserLoginCallback(authSource, ctx.Req, ctx.Resp) + u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) if err != nil { if user_model.IsErrUserProhibitLogin(err) { uplerr := err.(user_model.ErrUserProhibitLogin) @@ -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,18 +1085,18 @@ 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 { - _, err := auth.GetTwoFactorByUID(u.ID) + _, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserSignIn", err) return @@ -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 @@ -1208,7 +1202,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful // login the user -func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { +func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { oauth2Source := authSource.Cfg.(*oauth2.Source) // Make sure that the response is not an error response. @@ -1260,7 +1254,7 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res LoginSource: authSource.ID, } - hasUser, err := user_model.GetUser(user) + hasUser, err := user_model.GetUser(ctx, user) if err != nil { return nil, goth.User{}, err } @@ -1274,7 +1268,7 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res 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 00fc17f098..2143b8096a 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 } @@ -157,7 +140,7 @@ func signInOpenIDVerify(ctx *context.Context) { /* Now we should seek for the user and log him in, or prompt * to register if not found */ - u, err := user_model.GetUserByOpenID(id) + u, err := user_model.GetUserByOpenID(ctx, id) if err != nil { if !user_model.IsErrUserNotExist(err) { ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{ @@ -280,7 +263,7 @@ func ConnectOpenIDPost(ctx *context.Context) { ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp ctx.Data["OpenID"] = oid - u, _, err := auth.UserSignIn(form.UserName, form.Password) + u, _, err := auth.UserSignIn(ctx, form.UserName, form.Password) if err != nil { handleSignInError(ctx, form.UserName, &form, tplConnectOID, "ConnectOpenIDPost", err) return diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index b34a1d8fce..f6b76c1ffd 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -5,21 +5,23 @@ package auth import ( "errors" + "fmt" "net/http" "code.gitea.io/gitea/models/auth" 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 ( @@ -34,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 @@ -78,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 @@ -86,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) @@ -108,18 +108,18 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto } if len(code) == 0 { - ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) return nil, nil } // Fail early, don't frustrate the user - u := user_model.VerifyUserActiveCode(code) + u := user_model.VerifyUserActiveCode(ctx, code) if u == nil { - ctx.Flash.Error(ctx.Tr("auth.invalid_code")) + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) return nil, nil } - twofa, err := auth.GetTwoFactorByUID(u.ID) + twofa, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil { if !auth.IsErrTwoFactorNotEnrolled(err) { ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) @@ -134,7 +134,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto ctx.Data["user_email"] = u.Email if nil != ctx.Doer && u.ID != ctx.Doer.ID { - ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email)) + ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.Doer.Email, u.Email), true) return nil, nil } @@ -166,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 { @@ -216,24 +192,33 @@ func ResetPasswdPost(ctx *context.Context) { } twofa.LastUsedPasscode = passcode - if err = auth.UpdateTwoFactor(twofa); err != nil { + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) return } } } - var err error - if u.Rands, err = user_model.GetUserSalt(); err != nil { - ctx.ServerError("UpdateUser", err) - return + + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(ctx.FormString("password")), + MustChangePassword: optional.Some(false), } - if err = u.SetPassword(passwd); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - 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): + log.Error("%s", err.Error()) + ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplResetPassword, nil) + default: + ctx.ServerError("UpdateAuth", err) + } return } @@ -243,12 +228,12 @@ 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 } - if err = auth.UpdateTwoFactor(twofa); err != nil { + if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { ctx.ServerError("UserSignIn", err) return } @@ -283,11 +268,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 } @@ -297,48 +282,38 @@ 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 + opts := &user_service.UpdateAuthOptions{ + Password: optional.Some(form.Password), + MustChangePassword: optional.Some(false), } - - if !password.IsComplexEnough(form.Password) { - ctx.Data["Err_Password"] = true - ctx.RenderWithErr(password.BuildComplexityError(ctx.Locale), tplMustChangePassword, &form) - return - } - 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): + log.Error("%s", err.Error()) + 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 013e11eacc..1079f44a08 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 3125d7ce9a..0000000000 --- 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 e4b7d8ce8d..78dde57fa6 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 525ca9be53..dd20663f94 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 1a5a162c1a..52f20e07dc 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 94d83818fc..ecd7c33e01 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 e37bce6b40..f8fd6ec38e 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 e5f7977abd..66477a255c 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 132ef23fa7..b4507ba28d 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 a2b5f80099..b79a79fb2c 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,14 +80,20 @@ 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) { - users, count, err = user_model.SearchUsers(opts) + users, count, err = user_model.SearchUsers(ctx, opts) if err != nil { ctx.ServerError("SearchUsers", err) return @@ -108,7 +114,7 @@ func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, ctx.Data["Keyword"] = opts.Keyword ctx.Data["Total"] = count ctx.Data["Users"] = users - ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus() + ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx) ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled @@ -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 f13038ff9b..80ce2ad198 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 3775ba495a..3defa436a7 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 56a9c54ddc..1ab768ff27 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 ce86727e24..08cbcd9e12 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 fbfa11c63e..273f47e3b4 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 8931dae8cc..a41808c24a 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 5fcad26779..bfcc3a37d6 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 0000000000..5f1dedce76 --- /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 c5b8b6cbc0..8d5612ebfe 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 ecb73a928f..85f47613f0 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 b94e3e9eb5..d4be0931e8 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 } @@ -69,10 +68,10 @@ func Home(ctx *context.Context) { func HomeSitemap(ctx *context.Context) { m := sitemap.NewSitemapIndex() if !setting.Service.Explore.DisableUsersPage { - _, cnt, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + _, 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 c91da9a7f1..2dbbd6fc09 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 54c93763f6..ac5496ce91 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 72c09a3780..5fddfa8885 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 01b71e7086..f1cc7bf530 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 0000000000..d40458e250 --- /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 613dff2182..846b1de18a 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) @@ -123,24 +133,18 @@ func Home(ctx *context.Context) { PublicOnly: ctx.Org.PublicMemberOnly, ListOptions: db.ListOptions{Page: 1, PageSize: 25}, } - members, _, err := organization.FindOrgMembers(opts) + members, _, err := organization.FindOrgMembers(ctx, opts) if err != nil { ctx.ServerError("FindOrgMembers", err) 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,11 +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["ContextUser"] = ctx.ContextUser 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 41323a3601..92237d6e88 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 fae8b48128..63ac57cf0d 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -9,10 +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 ( @@ -37,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 @@ -46,66 +48,74 @@ 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 } + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + pager := context.NewPagination(int(total), setting.UI.MembersPagingNum, page, 5) opts.ListOptions.Page = page opts.ListOptions.PageSize = setting.UI.MembersPagingNum - members, membersIsPublic, err := organization.FindOrgMembers(opts) + members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts) if err != nil { ctx.ServerError("GetMembers", err) return } ctx.Data["Page"] = pager ctx.Data["Members"] = members - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["MembersIsPublicMember"] = membersIsPublic - ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(members, org.ID) - ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus() + ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) + ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) ctx.HTML(http.StatusOK, tplMembers) } // 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 f67e7edb4c..f94dd16eae 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 2c7725e38d..02eae8052e 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 4032162b5c..596a370d2e 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 08a97b7d2d..f4ccfe1c06 100644 --- a/routers/web/org/projects_test.go +++ b/routers/web/org/projects_test.go @@ -7,16 +7,16 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/routers/web/org" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) func TestCheckProjectBoardChangePermissions(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/-/projects/4/4") - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "user2/-/projects/4/4") + contexttest.LoadUser(t, ctx, 2) ctx.ContextUser = ctx.Doer // user2 ctx.SetParams(":id", "4") ctx.SetParams(":boardID", "4") diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 957daab646..494ada4323 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,12 +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" @@ -46,6 +47,13 @@ func Settings(ctx *context.Context) { ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess ctx.Data["ContextUser"] = ctx.ContextUser + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsOptions) } @@ -63,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 := org.Visibility != form.Visibility - visibilityChanged := form.Visibility != org.Visibility - 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 { @@ -152,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()) } @@ -172,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") @@ -189,6 +194,12 @@ func SettingsDelete(ctx *context.Context) { return } + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsDelete) } @@ -201,19 +212,25 @@ 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 } + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.Data["Webhooks"] = ws ctx.HTML(http.StatusOK, tplSettingsHooks) } // 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")) @@ -228,5 +245,12 @@ func Labels(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsOrgSettingsLabels"] = true ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsLabels) } diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go index c3c771036a..fe05709237 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 9bf4280b07..7f855795d3 100644 --- a/routers/web/org/setting_oauth2.go +++ b/routers/web/org/setting_oauth2.go @@ -8,10 +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 ( @@ -34,13 +36,21 @@ 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 } ctx.Data["Applications"] = apps + err = shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplSettingsApplications) } diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go index 21d25bd90a..af9836e42c 100644 --- a/routers/web/org/setting_packages.go +++ b/routers/web/org/setting_packages.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/packages" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" ) const ( @@ -24,6 +25,12 @@ func Packages(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + shared.SetPackagesContext(ctx, ctx.ContextUser) ctx.HTML(http.StatusOK, tplSettingsPackages) @@ -34,6 +41,12 @@ func PackagesRuleAdd(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + shared.SetRuleAddContext(ctx) ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) @@ -44,6 +57,12 @@ func PackagesRuleEdit(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + shared.SetRuleEditContext(ctx, ctx.ContextUser) ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit) @@ -80,6 +99,12 @@ func PackagesRulePreview(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + shared.SetRulePreviewContext(ctx, ctx.ContextUser) ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview) diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 3b07bba713..144d9b1b43 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,14 +21,15 @@ 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" + repo_service "code.gitea.io/gitea/services/repository" ) const ( @@ -56,7 +58,12 @@ func Teams(ctx *context.Context) { } } ctx.Data["Teams"] = ctx.Org.Teams - ctx.Data["ContextUser"] = ctx.ContextUser + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } ctx.HTML(http.StatusOK, tplTeams) } @@ -71,9 +78,9 @@ func TeamsAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - err = models.AddTeamMember(ctx.Org.Team, ctx.Doer.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) case "leave": - err = models.RemoveTeamMember(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")) @@ -94,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.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")) @@ -120,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 { @@ -152,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.Org.Team, u.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, u) } page = "team" @@ -183,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{ @@ -230,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")) @@ -240,13 +249,13 @@ func TeamsRepoAction(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - err = org_service.TeamAddRepository(ctx.Org.Team, repo) + err = org_service.TeamAddRepository(ctx, ctx.Org.Team, repo) case "remove": - err = models.RemoveRepository(ctx.Org.Team, ctx.FormInt64("repoid")) + err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid")) case "addall": - err = models.AddAllRepositories(ctx.Org.Team) + err = models.AddAllRepositories(ctx, ctx.Org.Team) case "removeall": - err = models.RemoveAllRepositories(ctx.Org.Team) + err = models.RemoveAllRepositories(ctx, ctx.Org.Team) } if err != nil { @@ -269,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) } @@ -345,7 +358,7 @@ func NewTeamPost(ctx *context.Context) { return } - if err := models.NewTeam(t); err != nil { + if err := models.NewTeam(ctx, t); err != nil { ctx.Data["Err_TeamName"] = true switch { case org_model.IsErrTeamAlreadyExist(err): @@ -364,6 +377,12 @@ func TeamMembers(ctx *context.Context) { ctx.Data["Title"] = ctx.Org.Team.Name ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamMembers"] = true + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + if err := ctx.Org.Team.LoadMembers(ctx); err != nil { ctx.ServerError("GetMembers", err) return @@ -386,6 +405,12 @@ func TeamRepositories(ctx *context.Context) { ctx.Data["Title"] = ctx.Org.Team.Name ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamRepos"] = true + + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + if err := ctx.Org.Team.LoadRepositories(ctx); err != nil { ctx.ServerError("GetRepositories", err) return @@ -409,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{ @@ -444,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) @@ -507,7 +536,7 @@ func EditTeamPost(ctx *context.Context) { return } - if err := models.UpdateTeam(t, isAuthChanged, isIncludeAllChanged); err != nil { + if err := models.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Data["Err_TeamName"] = true switch { case org_model.IsErrTeamAlreadyExist(err): @@ -522,7 +551,7 @@ func EditTeamPost(ctx *context.Context) { // DeleteTeam response for the delete team request func DeleteTeam(ctx *context.Context) { - if err := models.DeleteTeam(ctx.Org.Team); err != nil { + if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Flash.Error("DeleteTeam: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) @@ -564,7 +593,7 @@ func TeamInvitePost(ctx *context.Context) { return } - if err := models.AddTeamMember(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 0000000000..0d10a69dfe --- /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 6284d21463..6059ad1414 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 0000000000..6fa951826c --- /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 e4ca6a7198..41989589be 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 @@ -486,8 +521,9 @@ type ArtifactsViewResponse struct { } type ArtifactsViewItem struct { - Name string `json:"name"` - Size int64 `json:"size"` + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` } func ArtifactsView(ctx *context_module.Context) { @@ -510,14 +546,42 @@ func ArtifactsView(ctx *context_module.Context) { Artifacts: make([]*ArtifactsViewItem, 0, len(artifacts)), } for _, art := range artifacts { + status := "completed" + if art.Status == actions_model.ArtifactStatusExpired { + status = "expired" + } artifactsResponse.Artifacts = append(artifactsResponse.Artifacts, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, + Name: art.ArtifactName, + Size: art.FileSize, + Status: status, }) } 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") @@ -532,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 @@ -542,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 { @@ -602,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 3d030edaca..6f6641cc65 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 b7be77914f..f0c5622aec 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, @@ -77,7 +77,7 @@ func DeleteAttachment(ctx *context.Context) { ctx.Error(http.StatusForbidden) return } - err = repo_model.DeleteAttachment(attach, true) + err = repo_model.DeleteAttachment(ctx, attach, true) if err != nil { ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) return @@ -122,7 +122,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { } } - if err := attach.IncreaseDownloadCount(); err != nil { + if err := attach.IncreaseDownloadCount(ctx); err != nil { ctx.ServerError("IncreaseDownloadCount", err) return } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index b1cb42297c..1887e4d95d 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 d71d555bc2..f879a98786 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,11 +38,11 @@ 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) || - (ctx.IsSigned && repo_model.HasForkedRepo(ctx.Doer.ID, ctx.Repo.Repository.ID)) + (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) ctx.Data["PageIsViewCode"] = true ctx.Data["PageIsBranches"] = true @@ -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 5017d02252..088f8d889d 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 0000000000..c76f492da0 --- /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 9a620f6d37..8543fa44cc 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 aee3495612..cfb0e859bd 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 { @@ -357,7 +366,7 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo { // "OwnForkRepo" var ownForkRepo *repo_model.Repository if ctx.Doer != nil && baseRepo.OwnerID != ctx.Doer.ID { - repo := repo_model.GetForkedRepo(ctx.Doer.ID, baseRepo.ID) + repo := repo_model.GetForkedRepo(ctx, ctx.Doer.ID, baseRepo.ID) if repo != nil { ownForkRepo = repo ctx.Data["OwnForkRepo"] = ownForkRepo @@ -381,13 +390,13 @@ func ParseCompareInfo(ctx *context.Context) *CompareInfo { // 5. If the headOwner has a fork of the baseRepo - use that if !has { - ci.HeadRepo = repo_model.GetForkedRepo(ci.HeadUser.ID, baseRepo.ID) + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ID) has = ci.HeadRepo != nil } // 6. If the baseRepo is a fork and the headUser has a fork of that use that if !has && baseRepo.IsFork { - ci.HeadRepo = repo_model.GetForkedRepo(ci.HeadUser.ID, baseRepo.ForkID) + ci.HeadRepo = repo_model.GetForkedRepo(ctx, ci.HeadUser.ID, baseRepo.ForkID) has = ci.HeadRepo != nil } @@ -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) @@ -804,7 +812,7 @@ func CompareDiff(ctx *context.Context) { ctx.Data["IsRepoToolbarCommits"] = true ctx.Data["IsDiffCompare"] = true - templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) + _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) if len(templateErrs) > 0 { ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) @@ -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 0000000000..5fda17469e --- /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 a9e2e2b2fa..c4a8baecca 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 b053e3c63f..474f7ff1da 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), @@ -812,7 +817,7 @@ func UploadFileToServer(ctx *context.Context) { return } - upload, err := repo_model.NewUpload(name, buf, file) + upload, err := repo_model.NewUpload(ctx, name, buf, file) if err != nil { ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewUpload: %v", err)) return @@ -832,7 +837,7 @@ func RemoveUploadFileFromServer(ctx *context.Context) { return } - if err := repo_model.DeleteUploadByUUID(form.File); err != nil { + if err := repo_model.DeleteUploadByUUID(ctx, form.File); err != nil { ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) return } diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go index 52dded68b7..313fcfe33a 100644 --- a/routers/web/repo/editor_test.go +++ b/routers/web/repo/editor_test.go @@ -8,7 +8,8 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -41,12 +42,12 @@ func TestCleanUploadName(t *testing.T) { func TestGetUniquePatchBranchName(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() expectedBranchName := "user2-patch-1" @@ -56,17 +57,17 @@ func TestGetUniquePatchBranchName(t *testing.T) { func TestGetClosestParentWithFiles(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() 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 daefe59c8f..9da4237c1e 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 0000000000..27e42a8f98 --- /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 c8ecb3b1d8..8fb6d93068 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 @@ -158,7 +157,7 @@ func httpBase(ctx *context.Context) *serviceHandler { } if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true { - _, err = auth_model.GetTwoFactorByUID(ctx.Doer.ID) + _, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID) if err == nil { // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page") @@ -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, - } + ctx.Req.URL.Path = strings.ToLower(ctx.Req.URL.Path) // blue: In case some repo name has upper case name - r.URL.Path = strings.ToLower(r.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 (h *serviceHandler) setHeaderCacheForever() { +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 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 f8cdefdc8e..5e1e116018 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 b8b5a2dff2..6c2d4a7390 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -9,9 +9,11 @@ import ( stdCtx "context" "errors" "fmt" + "html/template" "math/big" "net/http" "net/url" + "slices" "sort" "strconv" "strings" @@ -30,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 ( @@ -129,13 +135,13 @@ func MustAllowPulls(ctx *context.Context) { } // User can send pull request if owns a forked repository. - if ctx.IsSigned && repo_model.HasForkedRepo(ctx.Doer.ID, ctx.Repo.Repository.ID) { + if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { ctx.Repo.PullRequest.Allowed = true ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Doer.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName) } } -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") @@ -181,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) } } @@ -197,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) @@ -273,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, @@ -299,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) @@ -407,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 @@ -416,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 @@ -424,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 } @@ -472,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 } @@ -489,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) @@ -513,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) @@ -547,52 +587,63 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { if repo.Owner.IsOrganization() { repoOwnerType = project_model.TypeOrganization } + + projectsUnit := repo.MustGetUnit(ctx, unit.TypeProjects) + + var openProjects []*project_model.Project + var closedProjects []*project_model.Project 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 + + 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 + } } - ctx.Data["OpenProjects"] = append(projects, projects2...) - - 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 - } - 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 @@ -610,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 @@ -675,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) @@ -824,15 +871,16 @@ 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 } -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) map[string]error { +// Tries to load and set an issue template. The first return value indicates if a template was loaded. +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) { commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) if err != nil { - return nil + return false, nil } templateCandidates := make([]string, 0, 1+len(possibleFiles)) @@ -895,16 +943,19 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles ctx.Data["label_ids"] = strings.Join(labelIDs, ",") ctx.Data["Reference"] = template.Ref ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName() - return templateErrs + return true, templateErrs } - return templateErrs + return false, templateErrs } // NewIssue render creating issue page func NewIssue(ctx *context.Context) { + issueConfig, _ := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + hasTemplates := issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.Data["NewIssueChooseTemplate"] = hasTemplates ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes title := ctx.FormString("title") ctx.Data["TitleQuery"] = title @@ -953,26 +1004,31 @@ func NewIssue(ctx *context.Context) { } ctx.Data["Tags"] = tags - _, templateErrs := issue_service.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - if errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates); len(errs) > 0 { - for k, v := range errs { - templateErrs[k] = v - } + ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates) + 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) + if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { + // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. + ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) + return + } + 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) @@ -984,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 } @@ -1001,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) { @@ -1167,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 } @@ -1199,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)) @@ -1253,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 } @@ -1289,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 } @@ -1317,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 { @@ -1394,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) @@ -1479,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) @@ -1518,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 @@ -1533,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 @@ -1555,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) @@ -1596,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 } @@ -1607,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 @@ -1616,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 { @@ -1636,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 @@ -1649,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 } @@ -1697,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 } @@ -1711,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 @@ -1743,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 { @@ -1776,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) @@ -1810,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 } @@ -1894,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 } @@ -1931,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 } @@ -1963,7 +2016,7 @@ func ViewIssue(ctx *context.Context) { var hiddenCommentTypes *big.Int if ctx.IsSigned { - val, err := user_model.GetUserSetting(ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) if err != nil { ctx.ServerError("GetUserSetting", err) return @@ -1987,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) { @@ -2193,8 +2246,12 @@ func UpdateIssueContent(ctx *context.Context) { return } - if err := issue_service.ChangeContent(issue, ctx.Doer, ctx.Req.FormValue("content")); err != nil { - ctx.ServerError("ChangeContent", err) + if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content")); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.edit.blocked_user")) + } else { + ctx.ServerError("ChangeContent", err) + } return } @@ -2207,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) @@ -2249,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 } @@ -2271,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 } @@ -2439,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 } @@ -2455,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 ( @@ -2475,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, @@ -2498,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") == "" { @@ -2521,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 @@ -2537,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 @@ -2576,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, @@ -2607,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) } } @@ -2679,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") @@ -2696,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 @@ -2708,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 @@ -2733,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 @@ -2775,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 @@ -2799,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) @@ -3025,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 } @@ -3054,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 } @@ -3074,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 @@ -3093,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 } @@ -3111,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) @@ -3140,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 @@ -3194,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 } @@ -3212,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 } @@ -3238,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(), @@ -3266,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 { @@ -3296,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 } @@ -3307,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 } @@ -3340,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(), @@ -3409,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 @@ -3439,7 +3538,7 @@ func updateAttachments(ctx *context.Context, item any, files []string) error { if util.SliceContainsString(files, attachments[i].UUID) { continue } - if err := repo_model.DeleteAttachment(attachments[i], true); err != nil { + if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil { return err } } @@ -3447,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) } @@ -3468,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, @@ -3562,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 @@ -3570,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 @@ -3620,7 +3719,7 @@ func issuePosters(ctx *context.Context, isPullList bool) { if search == "" && ctx.Doer != nil { // the returned posters slice only contains limited number of users, // to make the current user (doer) can quickly filter their own issues, always add doer to the posters slice - if !util.SliceContainsFunc(posters, func(user *user_model.User) bool { return user.ID == ctx.Doer.ID }) { + if !slices.ContainsFunc(posters, func(user *user_model.User) bool { return user.ID == ctx.Doer.ID }) { posters = append(posters, ctx.Doer) } } diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go index 3dd7725c21..bf3571c835 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 5b9c570b74..e3b85ee638 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 257610d3af..81bee4dbb5 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 e29582f968..93fc72300b 100644 --- a/routers/web/repo/issue_label_test.go +++ b/routers/web/repo/issue_label_test.go @@ -13,6 +13,7 @@ import ( "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" @@ -32,9 +33,9 @@ func int64SliceToCommaSeparated(a []int64) string { func TestInitializeLabels(t *testing.T) { unittest.PrepareTestEnv(t) assert.NoError(t, repository.LoadRepoConfig()) - ctx, _ := test.MockContext(t, "user2/repo1/labels/initialize") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/initialize") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) web.SetForm(ctx, &forms.InitializeLabelsForm{TemplateName: "Default"}) InitializeLabels(ctx) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) @@ -57,9 +58,9 @@ func TestRetrieveLabels(t *testing.T) { {1, "leastissues", []int64{2, 1}}, {2, "", []int64{}}, } { - ctx, _ := test.MockContext(t, "user/repo/issues") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, testCase.RepoID) + ctx, _ := contexttest.MockContext(t, "user/repo/issues") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, testCase.RepoID) ctx.Req.Form.Set("sort", testCase.Sort) RetrieveLabels(ctx) assert.False(t, ctx.Written()) @@ -75,9 +76,9 @@ func TestRetrieveLabels(t *testing.T) { func TestNewLabel(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/labels/edit") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.CreateLabelForm{ Title: "newlabel", Color: "#abcdef", @@ -93,9 +94,9 @@ func TestNewLabel(t *testing.T) { func TestUpdateLabel(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/labels/edit") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.CreateLabelForm{ ID: 2, Title: "newnameforlabel", @@ -114,22 +115,22 @@ func TestUpdateLabel(t *testing.T) { func TestDeleteLabel(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/labels/delete") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("id", "2") DeleteLabel(ctx) 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) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("issue_ids", "1,3") ctx.Req.Form.Set("action", "clear") UpdateIssueLabel(ctx) @@ -152,9 +153,9 @@ func TestUpdateIssueLabel_Toggle(t *testing.T) { {"toggle", []int64{1, 2}, 2, true}, } { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("issue_ids", int64SliceToCommaSeparated(testCase.IssueIDs)) ctx.Req.Form.Set("action", testCase.Action) ctx.Req.Form.Set("id", strconv.Itoa(int(testCase.LabelID))) diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go index 93f5a588d9..1d5fc8a5f3 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 f853f72335..365c812681 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 3e715437e6..70d42b27c0 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 04ca65dd9a..241e434049 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 d3d3a2af21..8b033f3b17 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 e3a09a95bd..6e469cf2ed 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 216550ca99..420931c5fb 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.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) } } @@ -72,12 +77,12 @@ func SetWhitespaceBehavior(ctx *context.Context) { whitespaceBehavior = defaultWhitespaceBehavior } if ctx.IsSigned { - userWhitespaceBehavior, err := user_model.GetUserSetting(ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, defaultWhitespaceBehavior) + userWhitespaceBehavior, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, defaultWhitespaceBehavior) if err == nil { if whitespaceBehavior == "" { whitespaceBehavior = userWhitespaceBehavior } else if whitespaceBehavior != userWhitespaceBehavior { - _ = user_model.SetUserSetting(ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, whitespaceBehavior) + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyDiffWhitespaceBehavior, whitespaceBehavior) } } // else: we can ignore the error safely } @@ -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.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.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 a6125a1a58..97b0c425ea 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 ad355ce5d7..95a4fe60cc 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 6ad2f71b5c..57e578da37 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) @@ -58,7 +58,6 @@ func Packages(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["Query"] = query ctx.Data["PackageType"] = packageType ctx.Data["AvailableTypes"] = packages.TypeList @@ -71,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 5faf9f4fa9..0dee02dd9c 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 eef57f4627..a2db1fc770 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 + 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 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 { @@ -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 e2797772a8..479f8c55a2 100644 --- a/routers/web/repo/projects_test.go +++ b/routers/web/repo/projects_test.go @@ -7,16 +7,16 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) func TestCheckProjectBoardChangePermissions(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/projects/1/2") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/projects/1/2") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) ctx.SetParams(":id", "1") ctx.SetParams(":boardID", "2") diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index e697a0d5b6..a0a8e5410c 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/notification" "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.Doer.ID, forkRepo.ID) - - ctx.Data["ForkRepo"] = forkRepo - - ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(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(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(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,12 +1318,30 @@ 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 + 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) + } + 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 } - ctx.ServerError("NewPullRequest", err) - return } log.Trace("Pull request created: %d/%d", repo.ID, pullIssue.ID) @@ -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): @@ -1672,7 +1581,7 @@ func UpdatePullRequestTarget(ctx *context.Context) { } return } - notification.NotifyPullRequestChangeTargetBranch(ctx, ctx.Doer, pr, targetBranch) + notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, targetBranch) ctx.JSON(http.StatusOK, map[string]any{ "base_branch": pr.BaseBranch, diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 3e433dcf4d..c8d149a482 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 0000000000..8344ff4091 --- /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 + } + + 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 07e349811e..7ebea4c3fb 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/test" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/forms" "github.com/stretchr/testify/assert" @@ -47,10 +49,10 @@ func TestNewReleasePost(t *testing.T) { } { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/releases/new") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) - test.LoadGitRepo(t, ctx) + ctx, _ := contexttest.MockContext(t, "user2/repo1/releases/new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadGitRepo(t, ctx) web.SetForm(ctx, &testCase.Form) NewReleasePost(ctx) unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ @@ -65,16 +67,26 @@ func TestNewReleasePost(t *testing.T) { } } -func TestNewReleasesList(t *testing.T) { +func TestCalReleaseNumCommitsBehind(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo-release/releases") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 57) - test.LoadGitRepo(t, ctx) + ctx, _ := contexttest.MockContext(t, "user2/repo-release/releases") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 57) + 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 f07b4e8c11..e64db03e20 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 4409381bc5..4e448933c7 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" "code.gitea.io/gitea/models" @@ -20,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 ( @@ -79,7 +82,7 @@ func CommitInfoCache(ctx *context.Context) { } func checkContextUser(ctx *context.Context, uid int64) *user_model.User { - orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx.Doer.ID) + orgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetOrgsCanCreateRepoByUserID", err) return nil @@ -118,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 @@ -177,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) } @@ -240,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, @@ -275,18 +280,19 @@ func CreatePost(ctx *context.Context) { return } } else { - repo, err = repo_service.CreateRepository(ctx, ctx.Doer, ctxUser, repo_module.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), + 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.DefaultTrustModel, + ObjectFormatName: form.ObjectFormatName, }) if err == nil { log.Trace("Repository created [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) @@ -298,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.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.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": @@ -326,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 { @@ -343,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") } @@ -358,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")) @@ -376,12 +417,11 @@ 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 { - if repo_model.IsErrAttachmentNotExist(err) { - ctx.Error(http.StatusNotFound) - return - } ctx.ServerError("RedirectDownload", err) return } @@ -396,6 +436,23 @@ func RedirectDownload(ctx *context.Context) { ServeAttachment(ctx, att.UUID) return } + } 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, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) + if err != nil { + ctx.Error(http.StatusNotFound) + return + } + if att != nil { + ServeAttachment(ctx, att.UUID) + return + } } ctx.Error(http.StatusNotFound) } @@ -490,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, @@ -501,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)) @@ -535,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") @@ -561,47 +622,36 @@ func SearchRepo(ctx *context.Context) { } } - var err error + // 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 + } + repos, count, err := repo_model.SearchRepository(ctx, opts) if err != nil { - ctx.JSON(http.StatusInternalServerError, api.SearchError{ - OK: false, - Error: err.Error(), - }) + log.Error("SearchRepository: %v", err) + ctx.JSON(http.StatusInternalServerError, nil) return } ctx.SetTotalCountHeader(count) - // To improve performance when only the count is requested - if ctx.FormBool("count_only") { - 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) + latestCommitStatuses, err := commitstatus_service.FindReposLastestCommitStatuses(ctx, repos) if err != nil { - log.Error("FindBranchesByRepoAndBranchName: %v", err) - return - } - - // call the database O(1) times to get the commit statuses for all repos - repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoIDsToLatestCommitSHAs, db.ListOptions{}) - 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, @@ -615,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) } } @@ -634,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 { @@ -646,7 +697,7 @@ func GetBranchesList(ctx *context.Context) { } resp := &branchTagSearchResponse{} // always put default branch on the top if it exists - if util.SliceContains(branches, ctx.Repo.Repository.DefaultBranch) { + if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) } @@ -669,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 { @@ -680,7 +729,7 @@ func PrepareBranchList(ctx *context.Context) { return } // always put default branch on the top if it exists - if util.SliceContains(brs, ctx.Repo.Repository.DefaultBranch) { + if slices.Contains(brs, ctx.Repo.Repository.DefaultBranch) { brs = util.SliceRemoveAll(brs, ctx.Repo.Repository.DefaultBranch) brs = append([]string{ctx.Repo.Repository.DefaultBranch}, brs...) } diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index 3c0fa4bc00..46f0208453 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) + 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) + } + } else { + res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ContextLineNumber: 3, IsFuzzy: isFuzzy}) + if err != nil { + ctx.ServerError("GrepSearch", err) return } - ctx.Data["CodeIndexerUnavailable"] = true - } else { - ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) + 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 ae80f1db01..504f57cfc2 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")) + 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 b708422cbd..31f9f76d0f 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -4,23 +4,22 @@ package setting import ( + "errors" "net/http" "strings" - "code.gitea.io/gitea/models" - "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" ) // Collaboration render a repository's collaboration page @@ -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 := models.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 @@ -173,7 +186,7 @@ func AddTeamPost(ctx *context.Context) { return } - if err = org_service.TeamAddRepository(team, ctx.Repo.Repository); err != nil { + if err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { ctx.ServerError("TeamAddRepository", err) return } @@ -196,7 +209,7 @@ func DeleteTeam(ctx *context.Context) { return } - if err = models.RemoveRepository(team, ctx.Repo.Repository.ID); err != nil { + if err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID); err != nil { ctx.ServerError("team.RemoveRepositorys", err) return } diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go new file mode 100644 index 0000000000..881d148afc --- /dev/null +++ b/routers/web/repo/setting/default_branch.go @@ -0,0 +1,54 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + 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" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// SetDefaultBranchPost set default branch +func SetDefaultBranchPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.branches.update_default_branch") + ctx.Data["PageIsSettingsBranches"] = true + + repo.PrepareBranchList(ctx) + if ctx.Written() { + return + } + + repo := ctx.Repo.Repository + + switch ctx.FormString("action") { + case "default_branch": + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBranches) + return + } + + branch := ctx.FormString("branch") + 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 + } + + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + default: + ctx.NotFound("", nil) + } +} diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index 577706d454..abc3eb4af1 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 551327d44b..217a01c90c 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 d478acdde0..6dddade066 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 5a6fa56217..c414b853e5 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 5bfdb8f515..b30dc3b061 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -14,14 +14,10 @@ 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/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/setting" "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" @@ -53,52 +49,6 @@ func ProtectedBranchRules(ctx *context.Context) { ctx.HTML(http.StatusOK, tplBranches) } -// SetDefaultBranchPost set default branch -func SetDefaultBranchPost(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.settings.branches.update_default_branch") - ctx.Data["PageIsSettingsBranches"] = true - - repo.PrepareBranchList(ctx) - if ctx.Written() { - return - } - - repo := ctx.Repo.Repository - - switch ctx.FormString("action") { - case "default_branch": - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplBranches) - return - } - - 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(repo); err != nil { - ctx.ServerError("SetDefaultBranch", err) - return - } - } - - log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) - default: - ctx.NotFound("", nil) - } -} - // SettingsProtectedBranch renders the protected branch setting page func SettingsProtectedBranch(c *context.Context) { ruleName := c.FormString("rule_name") @@ -118,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 @@ -134,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 @@ -278,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 @@ -335,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 aafbd19e80..2c25b650b9 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 2c192e9790..a47d3b45e2 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 3d7a057602..d4d56bfc57 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 af09e240d5..00a5282f34 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 6f7c844ce7..09586cc68d 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -7,18 +7,19 @@ import ( "net/http" "testing" - "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" "code.gitea.io/gitea/models/perm" 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/setting" - "code.gitea.io/gitea/modules/test" "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" "github.com/stretchr/testify/assert" ) @@ -42,10 +43,10 @@ func TestAddReadOnlyDeployKey(t *testing.T) { } unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/settings/keys") + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 2) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) addKeyForm := forms.AddKeyForm{ Title: "read-only", @@ -71,10 +72,10 @@ func TestAddReadWriteOnlyDeployKey(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/settings/keys") + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 2) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 2) addKeyForm := forms.AddKeyForm{ Title: "read-write", @@ -94,10 +95,10 @@ func TestAddReadWriteOnlyDeployKey(t *testing.T) { func TestCollaborationPost(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadUser(t, ctx, 4) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("collaborator", "user4") @@ -129,10 +130,10 @@ func TestCollaborationPost(t *testing.T) { func TestCollaborationPost_InactiveUser(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadUser(t, ctx, 9) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 9) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("collaborator", "user9") @@ -152,10 +153,10 @@ func TestCollaborationPost_InactiveUser(t *testing.T) { func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadUser(t, ctx, 4) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadUser(t, ctx, 4) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("collaborator", "user4") @@ -193,9 +194,9 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { func TestCollaborationPost_NonExistentUser(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/issues/labels") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("collaborator", "user34") @@ -215,7 +216,7 @@ func TestCollaborationPost_NonExistentUser(t *testing.T) { func TestAddTeamPost(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "org26/repo43") + ctx, _ := contexttest.MockContext(t, "org26/repo43") ctx.Req.Form.Set("team", "team11") @@ -248,14 +249,14 @@ func TestAddTeamPost(t *testing.T) { AddTeamPost(ctx) - assert.True(t, models.HasRepository(team, re.ID)) + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.Empty(t, ctx.Flash.ErrorMsg) } func TestAddTeamPost_NotAllowed(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "org26/repo43") + ctx, _ := contexttest.MockContext(t, "org26/repo43") ctx.Req.Form.Set("team", "team11") @@ -288,14 +289,14 @@ func TestAddTeamPost_NotAllowed(t *testing.T) { AddTeamPost(ctx) - assert.False(t, models.HasRepository(team, re.ID)) + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } func TestAddTeamPost_AddTeamTwice(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "org26/repo43") + ctx, _ := contexttest.MockContext(t, "org26/repo43") ctx.Req.Form.Set("team", "team11") @@ -329,14 +330,14 @@ func TestAddTeamPost_AddTeamTwice(t *testing.T) { AddTeamPost(ctx) AddTeamPost(ctx) - assert.True(t, models.HasRepository(team, re.ID)) + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } func TestAddTeamPost_NonExistentTeam(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "org26/repo43") + ctx, _ := contexttest.MockContext(t, "org26/repo43") ctx.Req.Form.Set("team", "team-non-existent") @@ -369,7 +370,7 @@ func TestAddTeamPost_NonExistentTeam(t *testing.T) { func TestDeleteTeam(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "org3/team1/repo3") + ctx, _ := contexttest.MockContext(t, "org3/team1/repo3") ctx.Req.Form.Set("id", "2") @@ -402,5 +403,5 @@ func TestDeleteTeam(t *testing.T) { DeleteTeam(ctx) - assert.False(t, models.HasRepository(team, re.ID)) + assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) } diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go index 1005d1d9c6..45b6c0f39a 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 33ea2c206b..1a3549fea4 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 d22c3c6aa3..d81a695df9 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 c364e7090f..d11af4669f 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 15c85f6427..8aa9dbb1be 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -9,11 +9,13 @@ import ( gocontext "context" "encoding/base64" "fmt" + "html/template" "image" "io" "net/http" "net/url" "path" + "slices" "strings" "time" @@ -33,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" @@ -43,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" @@ -156,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 @@ -173,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 @@ -257,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() @@ -301,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 @@ -310,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() - } - } else { - ctx.Data["IsPlainText"] = true - buf := &bytes.Buffer{} - ctx.Data["EscapeStatus"], err = charset.EscapeControlStringReader(rd, buf, ctx.Locale) - if err != nil { - log.Error("Read failed: %v", err) + delete(ctx.Data, "IsMarkup") } + } - ctx.Data["FileContent"] = buf.String() + if ctx.Data["IsMarkup"] != true { + ctx.Data["IsPlainText"] = true + content, err := io.ReadAll(rd) + if err != nil { + 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) + } + + 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() @@ -346,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) @@ -370,7 +419,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st if workFlowErr != nil { ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) } - } else if util.SliceContains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { + } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) if len(warnings) > 0 { @@ -433,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()) @@ -468,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) @@ -487,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 { @@ -496,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 { @@ -568,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 @@ -575,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) @@ -586,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 { @@ -610,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{}) @@ -618,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) @@ -627,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 @@ -659,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 @@ -691,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) { @@ -700,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() @@ -710,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 + 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 } - defer dataRc.Close() - buf := make([]byte, 1024) - n, err := util.ReadAtMost(dataRc, buf) - if err != nil { - ctx.ServerError("ReadAtMost", err) - return - } - buf = buf[:n] - ctx.PageData["citationFileContent"] = string(buf) - break } } } @@ -754,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 @@ -791,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 } @@ -800,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 } @@ -823,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["Files"] = files + for _, f := range files { + if f.Commit == nil { + ctx.Data["HasFilesWithoutLatestCommit"] = true + break } - 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["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses) - ctx.Data["LatestCommitStatuses"] = statuses + if !loadLatestCommitData(ctx, latestCommit) { + return nil } branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() @@ -882,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 @@ -892,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 { @@ -902,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 @@ -954,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() { @@ -971,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 @@ -986,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 @@ -1029,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") @@ -1064,7 +1122,7 @@ func Watchers(ctx *context.Context) { ctx.Data["PageIsWatchers"] = true RenderUserCards(ctx, ctx.Repo.Repository.NumWatches, func(opts db.ListOptions) ([]*user_model.User, error) { - return repo_model.GetRepoWatchers(ctx.Repo.Repository.ID, opts) + return repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, opts) }, tplWatchers) } @@ -1074,7 +1132,7 @@ func Stars(ctx *context.Context) { ctx.Data["CardsTitle"] = ctx.Tr("repo.stargazers") ctx.Data["PageIsStargazers"] = true RenderUserCards(ctx, ctx.Repo.Repository.NumStars, func(opts db.ListOptions) ([]*user_model.User, error) { - return repo_model.GetStargazers(ctx.Repo.Repository, opts) + return repo_model.GetStargazers(ctx, ctx.Repo.Repository, opts) }, tplWatchers) } @@ -1090,7 +1148,7 @@ func Forks(ctx *context.Context) { pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.ItemsPerPage, page, 5) ctx.Data["Page"] = pager - forks, err := repo_model.GetForks(ctx.Repo.Repository, db.ListOptions{ + forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{ Page: pager.Paginater.Current(), PageSize: setting.ItemsPerPage, }) diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index 4de24e2a38..df15f61b17 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -18,18 +18,19 @@ 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" - "code.gitea.io/gitea/modules/notification" "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/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 { @@ -727,7 +740,7 @@ func NewWikiPost(ctx *context.Context) { return } - notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) + notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(wikiName)) } @@ -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 { @@ -771,7 +784,7 @@ func EditWikiPost(ctx *context.Context) { return } - notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) + notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName)) } @@ -788,7 +801,7 @@ func DeleteWikiPagePost(ctx *context.Context) { return } - notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) + notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) ctx.JSONRedirect(ctx.Repo.RepoLink + "/wiki/") } diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go index e1284fad67..8b5207f9d9 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/git" - "code.gitea.io/gitea/modules/test" + "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,9 +80,9 @@ func assertPagesMetas(t *testing.T, expectedNames []string, metas any) { func TestWiki(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/?action=_pages") + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") ctx.SetParams("*", "Home") - test.LoadRepo(t, ctx, 1) + contexttest.LoadRepo(t, ctx, 1) Wiki(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) assert.EqualValues(t, "Home", ctx.Data["Title"]) @@ -90,8 +92,8 @@ func TestWiki(t *testing.T) { func TestWikiPages(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/?action=_pages") - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_pages") + contexttest.LoadRepo(t, ctx, 1) WikiPages(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) @@ -100,9 +102,9 @@ func TestWikiPages(t *testing.T) { func TestNewWiki(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/?action=_new") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) NewWiki(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) assert.EqualValues(t, ctx.Tr("repo.wiki.new_page"), ctx.Data["Title"]) @@ -115,9 +117,9 @@ func TestNewWikiPost(t *testing.T) { } { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/?action=_new") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.NewWikiForm{ Title: title, Content: content, @@ -133,9 +135,9 @@ func TestNewWikiPost(t *testing.T) { func TestNewWikiPost_ReservedName(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/?action=_new") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_new") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.NewWikiForm{ Title: "_edit", Content: content, @@ -143,17 +145,17 @@ 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") } func TestEditWiki(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/Home?action=_edit") + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_edit") ctx.SetParams("*", "Home") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) EditWiki(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) assert.EqualValues(t, "Home", ctx.Data["Title"]) @@ -166,10 +168,10 @@ func TestEditWikiPost(t *testing.T) { "New/", } { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/Home?action=_new") + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_new") ctx.SetParams("*", "Home") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.NewWikiForm{ Title: title, Content: content, @@ -188,9 +190,9 @@ func TestEditWikiPost(t *testing.T) { func TestDeleteWikiPagePost(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/Home?action=_delete") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/Home?action=_delete") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) DeleteWikiPagePost(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) assertWikiNotExists(t, ctx.Repo.Repository, "Home") @@ -207,10 +209,10 @@ func TestWikiRaw(t *testing.T) { } { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1/wiki/raw/"+url.PathEscape(filepath)) + ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/raw/"+url.PathEscape(filepath)) ctx.SetParams("*", filepath) - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) WikiRaw(ctx) if filetype == "" { assert.EqualValues(t, http.StatusNotFound, ctx.Resp.Status(), "filepath: %s", filepath) @@ -220,3 +222,32 @@ func TestWikiRaw(t *testing.T) { } } } + +func TestDefaultWikiBranch(t *testing.T) { + unittest.PrepareTestEnv(t) + + 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 cab3d78cac..34b7969442 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 } @@ -53,6 +48,7 @@ func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { ctx.Data["RegistrationToken"] = token.Token ctx.Data["RunnerOwnerID"] = opts.OwnerID ctx.Data["RunnerRepoID"] = opts.RepoID + ctx.Data["SortType"] = opts.Sort pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) @@ -88,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 8d1516c91c..79c03e4e8c 100644 --- a/routers/web/shared/actions/variables.go +++ b/routers/web/shared/actions/variables.go @@ -4,20 +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" ) 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, }) @@ -28,52 +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 ( - nameRx = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") - forbiddenPrefixRx = regexp.MustCompile("(?i)^GIT(EA|HUB)_") - - forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") -) - -func NameRegexMatch(name string) error { - if !nameRx.MatchString(name) || forbiddenPrefixRx.MatchString(name) { - log.Error("Name %s, regex match error", name) - return errors.New("name has invalid character") - } - return nil -} - -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 := NameRegexMatch(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) } @@ -82,23 +43,8 @@ func UpdateVariable(ctx *context.Context, redirectURL string) { id := ctx.ParamsInt64(":variable_id") form := web.GetForm(ctx).(*forms.EditVariableForm) - if err := NameRegexMatch(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 } @@ -109,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 @@ -117,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 30c25374d1..57671ad8f1 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 c09ce51499..3bd421f86a 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -6,15 +6,16 @@ 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,14 +27,9 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - if err := actions.NameRegexMatch(form.Name); err != nil { - ctx.JSONError(ctx.Tr("secrets.creation.failed")) - return - } - - s, err := secret_model.InsertEncryptedSecret(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("InsertEncryptedSecret: %v", err) + log.Error("CreateOrUpdateSecret failed: %v", err) ctx.JSONError(ctx.Tr("secrets.creation.failed")) return } @@ -45,11 +41,13 @@ func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { id := ctx.FormInt64("id") - if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id, OwnerID: ownerID, RepoID: repoID}); err != nil { - log.Error("Delete secret %d failed: %v", id, err) + err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) + if err != nil { + log.Error("DeleteSecretByID(%d) failed: %v", id, err) ctx.JSONError(ctx.Tr("secrets.deletion.failed")) return } + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) ctx.JSONRedirect(redirectURL) } diff --git a/routers/web/shared/user/block.go b/routers/web/shared/user/block.go new file mode 100644 index 0000000000..8a2357623f --- /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 9b1918ed16..7531e1ba26 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) @@ -22,7 +28,6 @@ import ( func prepareContextForCommonProfile(ctx *context.Context) { ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["EnableFeed"] = setting.Other.EnableFeed ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink() } @@ -31,23 +36,22 @@ 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.ContextUser.ID) + openIDs, err := user_model.GetUserOpenIDs(ctx, ctx.ContextUser.ID) if err != nil { ctx.ServerError("GetUserOpenIDs", err) 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) @@ -57,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, }) @@ -66,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 { @@ -82,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) - } else { - profileReadmeBlob, _ = commit.GetBlobByPath("README.md") - } + ctx.Data["UserBlocking"] = block } } - return profileGitRepo, profileReadmeBlob, func() { +} + +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 { + 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 profileDbRepo, profileGitRepo, profileReadmeBlob, func() { if profileGitRepo != nil { _ = profileGitRepo.Close() } @@ -107,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 } @@ -119,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 { @@ -127,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 493c97aa67..fc39b504a9 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 7ad65cd51e..04f510161d 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 033f65c9c0..785c37b124 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 ( @@ -27,20 +28,23 @@ func CodeSearch(ctx *context.Context) { shared_user.PrepareContextForProfileBigAvatar(ctx) shared_user.RenderUserHeader(ctx) + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["Title"] = ctx.Tr("explore.code") - ctx.Data["ContextUser"] = ctx.ContextUser 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 == "" { @@ -71,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) @@ -96,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 @@ -109,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 c44e5a50af..ff6c2a6c36 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "regexp" + "slices" "sort" "strconv" "strings" @@ -23,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" @@ -58,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 @@ -85,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) @@ -104,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 @@ -134,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) @@ -162,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 { @@ -212,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 @@ -245,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) @@ -255,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 @@ -264,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 @@ -274,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 = util.SliceRemoveAllFunc(repoIDs, func(v int64) bool { - return !showRepoIds.Contains(v) + repoIDs = slices.DeleteFunc(repoIDs, func(v int64) bool { + return !showRepoIDs.Contains(v) }) var pagerCount int @@ -315,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) @@ -334,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) } @@ -348,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) } @@ -427,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, @@ -453,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 @@ -474,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: @@ -496,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") @@ -518,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 = util.SliceRemoveAllFunc(selectedRepoIDs, func(v int64) bool { - return !accessibleRepos.Contains(v) - }) - if len(selectedRepoIDs) > 0 { - opts.RepoIDs = selectedRepoIDs - } // ------------------------------ // Get issues as defined by opts. @@ -561,44 +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. - selectedReposMap := make(map[int64]struct{}, len(selectedRepoIDs)) - for _, repoID := range selectedRepoIDs { - selectedReposMap[repoID] = struct{}{} - } - for k, v := range issueCountByRepo { - if _, ok := selectedReposMap[k]; !ok && v == 0 { - 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) @@ -608,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 @@ -621,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 @@ -676,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 @@ -691,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 @@ -777,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 @@ -786,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) @@ -823,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"): @@ -845,7 +739,6 @@ func UsernameSubRoute(ctx *context.Context) { return } if reloadParam(".rss") { - context_service.UserAssignmentWeb()(ctx) feed.ShowUserFeedRSS(ctx) } case strings.HasSuffix(username, ".atom"): @@ -857,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) @@ -865,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 @@ -882,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 634a91545e..1cc9886308 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/setting" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -20,12 +23,12 @@ func TestArchivedIssues(t *testing.T) { setting.UI.IssuePagingNum = 1 assert.NoError(t, unittest.LoadFixtures()) - ctx, _ := test.MockContext(t, "issues") - test.LoadUser(t, ctx, 30) + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 30) 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,33 +47,29 @@ 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) { setting.UI.IssuePagingNum = 1 assert.NoError(t, unittest.LoadFixtures()) - ctx, _ := test.MockContext(t, "issues") - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "issues") + contexttest.LoadUser(t, ctx, 2) ctx.Req.Form.Set("state", "closed") 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) { setting.UI.IssuePagingNum = 20 assert.NoError(t, unittest.LoadFixtures()) - ctx, _ := test.MockContext(t, "pulls") - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "pulls") + contexttest.LoadUser(t, ctx, 2) ctx.Req.Form.Set("state", "open") Pulls(ctx) assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) @@ -82,8 +81,8 @@ func TestMilestones(t *testing.T) { setting.UI.IssuePagingNum = 1 assert.NoError(t, unittest.LoadFixtures()) - ctx, _ := test.MockContext(t, "milestones") - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.LoadUser(t, ctx, 2) ctx.SetParams("sort", "issues") ctx.Req.Form.Set("state", "closed") ctx.Req.Form.Set("sort", "furthestduedate") @@ -101,8 +100,8 @@ func TestMilestonesForSpecificRepo(t *testing.T) { setting.UI.IssuePagingNum = 1 assert.NoError(t, unittest.LoadFixtures()) - ctx, _ := test.MockContext(t, "milestones") - test.LoadUser(t, ctx, 2) + ctx, _ := contexttest.MockContext(t, "milestones") + contexttest.LoadUser(t, ctx, 2) ctx.SetParams("sort", "issues") ctx.SetParams("repo", "1") ctx.Req.Form.Set("state", "closed") @@ -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 925482a1d2..8b6ae69296 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 579287ffac..ae0132e6e2 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 d44638d48b..9af49406c4 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,13 +402,19 @@ 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, }) ctx.Data["Repos"] = repos ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + ctx.HTML(http.StatusOK, tplPackagesSettings) } @@ -433,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")) @@ -443,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 87505b94b1..f0749e1021 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 } - ctx.JSONOK() + + 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 + } + 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 fa2e52dd41..fb7729bbe1 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" ) @@ -19,7 +19,7 @@ func Search(ctx *context.Context) { PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), } - users, maxResults, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), UID: ctx.FormInt64("uid"), diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index ecb846e91b..c93b70af76 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,33 @@ 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): + log.Error("%s", err.Error()) + 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 +94,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(&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,14 +108,14 @@ 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 } id := ctx.FormInt64("id") - email, err := user_model.GetEmailAddressByID(ctx.Doer.ID, id) + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) if err != nil { log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) ctx.Redirect(setting.AppSubURL + "/user/settings/account") @@ -137,15 +140,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 +163,12 @@ func EmailPost(ctx *context.Context) { ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) return } - if err := user_model.SetEmailNotifications(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 +184,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(&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 +235,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.Doer.Name, ctx.FormString("password")); err != nil { - if user_model.IsErrUserNotExist(err) { + if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { + 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 +285,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) } @@ -267,7 +298,7 @@ func DeleteAccount(ctx *context.Context) { } func loadAccountData(ctx *context.Context) { - emlist, err := user_model.GetEmailAddresses(ctx.Doer.ID) + emlist, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetEmailAddresses", err) return @@ -276,7 +307,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 +316,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 ba840db288..9fdc5e4d53 100644 --- a/routers/web/user/setting/account_test.go +++ b/routers/web/user/setting/account_test.go @@ -9,8 +9,8 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" - "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" @@ -83,9 +83,9 @@ func TestChangePassword(t *testing.T) { t.Run(req.OldPassword+"__"+req.NewPassword, func(t *testing.T) { unittest.PrepareTestEnv(t) setting.PasswordComplexity = req.PasswordComplexity - ctx, _ := test.MockContext(t, "user/settings/security") - test.LoadUser(t, ctx, 2) - test.LoadRepo(t, ctx, 1) + ctx, _ := contexttest.MockContext(t, "user/settings/security") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.ChangePasswordForm{ OldPassword: req.OldPassword, diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go index 01668c3954..171c1933d4 100644 --- a/routers/web/user/setting/adopt.go +++ b/routers/web/user/setting/adopt.go @@ -8,10 +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" - repo_module "code.gitea.io/gitea/modules/repository" "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" ) @@ -45,7 +44,7 @@ func AdoptOrDeleteRepository(ctx *context.Context) { if has || !isDir { // Fallthrough to failure mode } else if action == "adopt" && allowAdopt { - if _, err := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_module.CreateRepoOptions{ + if _, err := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_service.CreateRepoOptions{ Name: dir, IsPrivate: true, }); err != nil { diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 088aba38b6..e3822ca988 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" ) @@ -53,7 +54,7 @@ func ApplicationsPost(ctx *context.Context) { Scope: scope, } - exist, err := auth_model.AccessTokenByNameExists(t) + exist, err := auth_model.AccessTokenByNameExists(ctx, t) if err != nil { ctx.ServerError("AccessTokenByNameExists", err) return @@ -64,7 +65,7 @@ func ApplicationsPost(ctx *context.Context) { return } - if err := auth_model.NewAccessToken(t); err != nil { + if err := auth_model.NewAccessToken(ctx, t); err != nil { ctx.ServerError("NewAccessToken", err) return } @@ -77,7 +78,7 @@ func ApplicationsPost(ctx *context.Context) { // DeleteApplication response for delete user access token func DeleteApplication(ctx *context.Context) { - if err := auth_model.DeleteAccessTokenByID(ctx.FormInt64("id"), ctx.Doer.ID); err != nil { + if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) @@ -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(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 0000000000..94fc380cee --- /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 2336c04bbe..9e969e045d 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" ) @@ -51,7 +53,7 @@ func KeysPost(ctx *context.Context) { } switch form.Type { case "principal": - content, err := asymkey_model.CheckPrincipalKeyString(ctx.Doer, form.Content) + content, err := asymkey_model.CheckPrincipalKeyString(ctx, ctx.Doer, form.Content) if err != nil { if db.IsErrSSHDisabled(err) { ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) @@ -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 c3938b3201..e398208d0d 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 93142c21fc..1f485e06c8 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 641cc1fd9f..85d1e820a5 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -9,9 +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" ) @@ -25,6 +26,14 @@ type OAuth2CommonHandlers struct { 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 != nil && ctx.ContextUser.IsOrganization() { + if err := shared_user.LoadHeaderCount(ctx); err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + } + ctx.HTML(http.StatusOK, oa.TplAppEdit) } @@ -53,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) } @@ -91,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"), @@ -121,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 @@ -132,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 0d2eb14c20..4132659495 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 ( @@ -107,7 +107,7 @@ func RegenerateChefKeyPair(ctx *context.Context) { return } - if err := user_model.SetUserSetting(ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil { + if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil { ctx.ServerError("SetUserSetting", err) return } diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 61089d0947..49eb050dcb 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,78 +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) + 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) + 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.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 } @@ -131,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 = "" } @@ -146,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")) + 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) @@ -156,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 == "" { @@ -170,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 @@ -190,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()) } @@ -215,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) @@ -288,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{ @@ -313,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 @@ -344,7 +320,7 @@ func Appearance(ctx *context.Context) { ctx.Data["PageIsSettingsAppearance"] = true var hiddenCommentTypes *big.Int - val, err := user_model.GetUserSetting(ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) if err != nil { ctx.ServerError("GetUserSetting", err) return @@ -375,14 +351,15 @@ func UpdateUIThemePost(ctx *context.Context) { return } - if err := user_model.UpdateUserTheme(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") } @@ -392,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.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 } @@ -410,13 +389,13 @@ 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") } // UpdateUserHiddenComments update a user's shown comment types func UpdateUserHiddenComments(ctx *context.Context) { - err := user_model.SetUserSetting(ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String()) + err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String()) if err != nil { ctx.ServerError("SetUserSetting", err) return diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go index 451fd0ca97..2bb10cceb9 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 0cecb1aa37..cd09102369 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" @@ -28,7 +28,7 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true - t, err := auth.GetTwoFactorByUID(ctx.Doer.ID) + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if err != nil { if auth.IsErrTwoFactorNotEnrolled(err) { ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) @@ -44,7 +44,7 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { return } - if err = auth.UpdateTwoFactor(t); err != nil { + if err = auth.UpdateTwoFactor(ctx, t); err != nil { ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err) return } @@ -58,7 +58,7 @@ func DisableTwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true - t, err := auth.GetTwoFactorByUID(ctx.Doer.ID) + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if err != nil { if auth.IsErrTwoFactorNotEnrolled(err) { ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) @@ -68,7 +68,7 @@ func DisableTwoFactor(ctx *context.Context) { return } - if err = auth.DeleteTwoFactorByID(t.ID, ctx.Doer.ID); err != nil { + if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil { if auth.IsErrTwoFactorNotEnrolled(err) { // There is a potential DB race here - we must have been disabled by another request in the intervening period ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) @@ -145,7 +145,7 @@ func EnrollTwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true - t, err := auth.GetTwoFactorByUID(ctx.Doer.ID) + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if t != nil { // already enrolled - we should redirect back! log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer) @@ -171,7 +171,7 @@ func EnrollTwoFactorPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true - t, err := auth.GetTwoFactorByUID(ctx.Doer.ID) + t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if t != nil { // already enrolled ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled")) @@ -237,7 +237,7 @@ func EnrollTwoFactorPost(ctx *context.Context) { log.Error("Unable to save changes to the session: %v", err) } - if err = auth.NewTwoFactor(t); err != nil { + if err = auth.NewTwoFactor(ctx, t); err != nil { // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. // If there is a unique constraint fail we should just tolerate the error ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go index b5509f570f..8f788e1735 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" ) @@ -44,7 +44,7 @@ func OpenIDPost(ctx *context.Context) { form.Openid = id log.Trace("Normalized id: " + id) - oids, err := user_model.GetUserOpenIDs(ctx.Doer.ID) + oids, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetUserOpenIDs", err) return @@ -105,7 +105,7 @@ func settingsOpenIDVerify(ctx *context.Context) { // DeleteOpenID response for delete user's openid func DeleteOpenID(ctx *context.Context) { - if err := user_model.DeleteUserOpenID(&user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { + if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { ctx.ServerError("DeleteUserOpenID", err) return } @@ -117,7 +117,7 @@ func DeleteOpenID(ctx *context.Context) { // ToggleOpenIDVisibility response for toggle visibility of user's openid func ToggleOpenIDVisibility(ctx *context.Context) { - if err := user_model.ToggleUserOpenIDVisibility(ctx.FormInt64("id")); err != nil { + if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil { ctx.ServerError("ToggleUserOpenIDVisibility", err) return } diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go index dae9bf950d..8d6859ab87 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")) @@ -52,28 +55,31 @@ func DeleteAccountLink(ctx *context.Context) { } func loadSecurityData(ctx *context.Context) { - enrolled, err := auth_model.HasTwoFactorByUID(ctx.Doer.ID) + enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("SettingsTwoFactor", err) return } 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(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,15 +111,35 @@ 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 - openid, err := user_model.GetUserOpenIDs(ctx.Doer.ID) + openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("GetUserOpenIDs", err) return diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 990e506d6f..e382c8b9af 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 04092461fd..4423b62781 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 d262c777c3..38f74ea455 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 d92bf64af0..8476767e9e 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 bbab9b37b5..4fff994e42 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -6,10 +6,12 @@ package web import ( gocontext "context" "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" @@ -19,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/routing" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/admin" @@ -38,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" @@ -46,7 +49,7 @@ import ( "gitea.com/go-chi/captcha" "github.com/NYTimes/gziphandler" - "github.com/go-chi/chi/v5/middleware" + chi_middleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/prometheus/client_golang/prometheus" ) @@ -56,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, @@ -71,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) + } + }) } } @@ -90,11 +108,117 @@ 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 } +func webAuth(authMethod auth_service.Method) func(*context.Context) { + return func(ctx *context.Context) { + ar, err := common.AuthShared(ctx.Base, ctx.Session, authMethod) + if err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, "Verify") + return + } + ctx.Doer = ar.Doer + ctx.IsSigned = ar.Doer != nil + ctx.IsBasicAuth = ar.IsBasicAuth + if ctx.Doer == nil { + // ensure the session uid is deleted + _ = ctx.Session.Delete("uid") + } + } +} + +// verifyAuthWithOptions checks authentication according to options +func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) { + return func(ctx *context.Context) { + // Check prohibit login users. + if ctx.IsSigned { + if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + + 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.Locale.TrString("auth.must_change_password")) + return + } + ctx.Data["Title"] = ctx.Tr("auth.must_change_password") + ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") + return + } + } else if ctx.Req.URL.Path == "/user/settings/change_password" { + // make sure that the form cannot be accessed by users who don't need this + ctx.Redirect(setting.AppSubURL + "/") + return + } + } + + // 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.RedirectToCurrentSite(ctx.FormString("redirect_to")) + return + } + + if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" { + ctx.Csrf.Validate(ctx) + if ctx.Written() { + return + } + } + + if options.SignInRequired { + if !ctx.IsSigned { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { + ctx.Data["Title"] = ctx.Tr("auth.active_your_account") + ctx.HTML(http.StatusOK, "user/auth/activate") + return + } + } + + // Redirect to log in page if auto-signin info is provided and has not signed in. + if !options.SignOutRequired && !ctx.IsSigned && + ctx.GetSiteCookie(setting.CookieRememberName) != "" { + if ctx.Req.URL.Path != "/user/events" { + middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + + if options.AdminRequired { + if !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden) + return + } + ctx.Data["PageIsAdmin"] = true + } + } +} + func ctxDataSet(args ...any) func(ctx *context.Context) { return func(ctx *context.Context) { for i := 0; i < len(args); i += 2 { @@ -108,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")) @@ -144,10 +268,10 @@ func Routes() *web.Route { mid = append(mid, common.Sessioner(), context.Contexter()) // Get user from session if logged in. - mid = append(mid, auth_service.Auth(buildAuthGroup())) + mid = append(mid, webAuth(buildAuthGroup())) // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route - mid = append(mid, middleware.GetHead) + mid = append(mid, chi_middleware.GetHead) if setting.API.EnableSwagger { // Note: The route is here but no in API routes because it renders a web page @@ -166,18 +290,20 @@ func Routes() *web.Route { return routes } +var ignSignInAndCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) + // registerRoutes register routes func registerRoutes(m *web.Route) { - reqSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: true}) - reqSignOut := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignOutRequired: true}) + reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) + reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true}) // TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView) - ignSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) - ignExploreSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) - ignSignInAndCsrf := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{DisableCSRF: true}) + ignSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) + ignExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) + 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 } @@ -305,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) @@ -346,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) { @@ -420,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) @@ -502,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) @@ -519,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() { @@ -543,17 +676,21 @@ func registerRoutes(m *web.Route) { m.Get("/avatar/{hash}", user.AvatarByEmailHash) - adminReq := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: true, AdminRequired: true}) + adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true}) // ***** 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() { @@ -573,7 +710,8 @@ func registerRoutes(m *web.Route) { m.Group("/users", func() { m.Get("", admin.Users) m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost) - m.Combo("/{userid}").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost) + m.Get("/{userid}", admin.ViewUser) + m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost) m.Post("/{userid}/delete", admin.DeleteUser) m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost) m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) @@ -637,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 } @@ -646,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) @@ -681,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() { @@ -741,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 } @@ -770,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) @@ -793,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 ***** @@ -804,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) @@ -850,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) }) @@ -861,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() { @@ -952,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() { @@ -1088,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() { @@ -1106,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 @@ -1130,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) @@ -1190,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) @@ -1216,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() { @@ -1229,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() @@ -1243,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() { @@ -1350,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) @@ -1369,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) @@ -1403,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 faa35b8d2f..a87c426b3b 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 0000000000..8e934d89a8 --- /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 0000000000..f73ae8ae4c --- /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 new file mode 100644 index 0000000000..5376c2624c --- /dev/null +++ b/services/actions/cleanup.go @@ -0,0 +1,78 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/storage" +) + +// Cleanup removes expired actions logs, data and artifacts +func Cleanup(taskCtx context.Context, olderThan time.Duration) error { + // TODO: clean up expired actions logs + + // clean up expired artifacts + return CleanupArtifacts(taskCtx) +} + +// 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 := 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 d2893e4f23..67373782d5 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 08a7dde67c..4236553927 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/init.go b/services/actions/init.go index 26573c1681..0f49cb6297 100644 --- a/services/actions/init.go +++ b/services/actions/init.go @@ -6,9 +6,9 @@ package actions import ( "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + notify_service "code.gitea.io/gitea/services/notify" ) func Init() { @@ -22,5 +22,5 @@ func Init() { } go graceful.GetManager().RunWithCancel(jobEmitterQueue) - notification.RegisterNotifier(NewNotifier()) + notify_service.RegisterNotifier(NewNotifier()) } diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index f7ec615364..d2bbbd9a7c 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 e81aa61d80..038df7d4f8 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 cfe2e284da..eec5f814da 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" @@ -15,28 +14,28 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/repository" "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" + notify_service "code.gitea.io/gitea/services/notify" ) type actionsNotifier struct { - base.NullNotifier + notify_service.NullNotifier } -var _ base.Notifier = &actionsNotifier{} +var _ notify_service.Notifier = &actionsNotifier{} // NewNotifier create a new actionsNotifier notifier -func NewNotifier() base.Notifier { +func NewNotifier() notify_service.Notifier { return &actionsNotifier{} } -// NotifyNewIssue notifies issue created event -func (n *actionsNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) { - ctx = withMethod(ctx, "NotifyNewIssue") +// NewIssue notifies issue created event +func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) { + ctx = withMethod(ctx, "NewIssue") if err := issue.LoadRepo(ctx); err != nil { log.Error("issue.LoadRepo: %v", err) return @@ -53,12 +52,53 @@ func (n *actionsNotifier) NotifyNewIssue(ctx context.Context, issue *issues_mode Issue: convert.ToAPIIssue(ctx, issue), Repository: convert.ToRepo(ctx, issue.Repo, permission), Sender: convert.ToUser(ctx, issue.Poster, nil), - }).Notify(withMethod(ctx, "NotifyNewIssue")) + }).Notify(withMethod(ctx, "NewIssue")) } -// NotifyIssueChangeStatus notifies close or reopen issue to notifiers -func (n *actionsNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { - ctx = withMethod(ctx, "NotifyIssueChangeStatus") +// 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, 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") permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) if issue.IsPull { if err := issue.LoadPullRequest(ctx); err != nil { @@ -68,7 +108,7 @@ func (n *actionsNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *use // 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, @@ -102,11 +142,58 @@ func (n *actionsNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *use Notify(ctx) } -func (n *actionsNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, +// 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, "NotifyIssueChangeLabels") + 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) NotifyIssueChangeLabels(ctx context.Context, doer *use 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,10 +223,11 @@ func (n *actionsNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *use 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), Repository: convert.ToRepo(ctx, issue.Repo, permission), @@ -153,48 +236,99 @@ func (n *actionsNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *use Notify(ctx) } -// NotifyCreateIssueComment notifies comment on an issue to notifiers -func (n *actionsNotifier) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, +// CreateIssueComment notifies comment on an issue to notifiers +func (n *actionsNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User, ) { - ctx = withMethod(ctx, "NotifyCreateIssueComment") - - permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer) + ctx = withMethod(ctx, "CreateIssueComment") 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, 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) } -func (n *actionsNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_model.PullRequest, _ []*user_model.User) { - ctx = withMethod(ctx, "NotifyNewPullRequest") +func (n *actionsNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, _ []*user_model.User) { + ctx = withMethod(ctx, "NewPullRequest") if err := pull.LoadIssue(ctx); err != nil { log.Error("pull.LoadIssue: %v", err) @@ -223,8 +357,8 @@ func (n *actionsNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues Notify(ctx) } -func (n *actionsNotifier) NotifyCreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { - ctx = withMethod(ctx, "NotifyCreateRepository") +func (n *actionsNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + ctx = withMethod(ctx, "CreateRepository") newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{ Action: api.HookRepoCreated, @@ -234,8 +368,8 @@ func (n *actionsNotifier) NotifyCreateRepository(ctx context.Context, doer, u *u }).Notify(ctx) } -func (n *actionsNotifier) NotifyForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { - ctx = withMethod(ctx, "NotifyForkRepository") +func (n *actionsNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { + ctx = withMethod(ctx, "ForkRepository") oldPermission, _ := access_model.GetUserRepoPermission(ctx, oldRepo, doer) permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer) @@ -262,8 +396,8 @@ func (n *actionsNotifier) NotifyForkRepository(ctx context.Context, doer *user_m } } -func (n *actionsNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) { - ctx = withMethod(ctx, "NotifyPullRequestReview") +func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) { + ctx = withMethod(ctx, "PullRequestReview") var reviewHookType webhook_module.HookEventType @@ -296,7 +430,7 @@ func (n *actionsNotifier) NotifyPullRequestReview(ctx context.Context, pr *issue 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,8 +440,41 @@ func (n *actionsNotifier) NotifyPullRequestReview(ctx context.Context, pr *issue }).Notify(ctx) } -func (*actionsNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - ctx = withMethod(ctx, "NotifyMergePullRequest") +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") // Reload pull request information. if err := pr.LoadAttributes(ctx); err != nil { @@ -320,7 +487,7 @@ func (*actionsNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_m 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) NotifyMergePullRequest(ctx context.Context, doer *user_m // 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, @@ -347,8 +514,14 @@ func (*actionsNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_m Notify(ctx) } -func (n *actionsNotifier) NotifyPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { - ctx = withMethod(ctx, "NotifyPushCommits") +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) apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) @@ -373,16 +546,16 @@ func (n *actionsNotifier) NotifyPushCommits(ctx context.Context, pusher *user_mo Notify(ctx) } -func (n *actionsNotifier) NotifyCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { - ctx = withMethod(ctx, "NotifyCreateRef") +func (n *actionsNotifier) CreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + ctx = withMethod(ctx, "CreateRef") apiPusher := convert.ToUser(ctx, pusher, nil) 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, @@ -391,16 +564,15 @@ func (n *actionsNotifier) NotifyCreateRef(ctx context.Context, pusher *user_mode Notify(ctx) } -func (n *actionsNotifier) NotifyDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { - ctx = withMethod(ctx, "NotifyDeleteRef") +func (n *actionsNotifier) DeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + ctx = withMethod(ctx, "DeleteRef") apiPusher := convert.ToUser(ctx, pusher, nil) 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, @@ -409,11 +581,11 @@ func (n *actionsNotifier) NotifyDeleteRef(ctx context.Context, pusher *user_mode Notify(ctx) } -func (n *actionsNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { - ctx = withMethod(ctx, "NotifySyncPushCommits") +func (n *actionsNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + 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 @@ -436,55 +608,59 @@ func (n *actionsNotifier) NotifySyncPushCommits(ctx context.Context, pusher *use Notify(ctx) } -func (n *actionsNotifier) NotifySyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { - ctx = withMethod(ctx, "NotifySyncCreateRef") - n.NotifyCreateRef(ctx, pusher, repo, refFullName, refID) +func (n *actionsNotifier) SyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + ctx = withMethod(ctx, "SyncCreateRef") + n.CreateRef(ctx, pusher, repo, refFullName, refID) } -func (n *actionsNotifier) NotifySyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { - ctx = withMethod(ctx, "NotifySyncDeleteRef") - n.NotifyDeleteRef(ctx, pusher, repo, refFullName) +func (n *actionsNotifier) SyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + ctx = withMethod(ctx, "SyncDeleteRef") + n.DeleteRef(ctx, pusher, repo, refFullName) } -func (n *actionsNotifier) NotifyNewRelease(ctx context.Context, rel *repo_model.Release) { - ctx = withMethod(ctx, "NotifyNewRelease") +func (n *actionsNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { + ctx = withMethod(ctx, "NewRelease") notifyRelease(ctx, rel.Publisher, rel, api.HookReleasePublished) } -func (n *actionsNotifier) NotifyUpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { - ctx = withMethod(ctx, "NotifyUpdateRelease") +func (n *actionsNotifier) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + ctx = withMethod(ctx, "UpdateRelease") notifyRelease(ctx, doer, rel, api.HookReleaseUpdated) } -func (n *actionsNotifier) NotifyDeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { - ctx = withMethod(ctx, "NotifyDeleteRelease") +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) } -func (n *actionsNotifier) NotifyPackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { - ctx = withMethod(ctx, "NotifyPackageCreate") +func (n *actionsNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + ctx = withMethod(ctx, "PackageCreate") notifyPackage(ctx, doer, pd, api.HookPackageCreated) } -func (n *actionsNotifier) NotifyPackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { - ctx = withMethod(ctx, "NotifyPackageDelete") +func (n *actionsNotifier) PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + ctx = withMethod(ctx, "PackageDelete") notifyPackage(ctx, doer, pd, api.HookPackageDeleted) } -func (n *actionsNotifier) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - ctx = withMethod(ctx, "NotifyAutoMergePullRequest") - n.NotifyMergePullRequest(ctx, doer, pr) +func (n *actionsNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ctx = withMethod(ctx, "AutoMergePullRequest") + n.MergePullRequest(ctx, doer, pr) } -func (n *actionsNotifier) NotifyPullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - ctx = withMethod(ctx, "NotifyPullRequestSynchronized") +func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ctx = withMethod(ctx, "PullRequestSynchronized") if err := pr.LoadIssue(ctx); err != nil { log.Error("LoadAttributes: %v", err) 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 } @@ -501,15 +677,15 @@ func (n *actionsNotifier) NotifyPullRequestSynchronized(ctx context.Context, doe Notify(ctx) } -func (n *actionsNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { - ctx = withMethod(ctx, "NotifyPullRequestChangeTargetBranch") +func (n *actionsNotifier) PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { + ctx = withMethod(ctx, "PullRequestChangeTargetBranch") if err := pr.LoadIssue(ctx); err != nil { log.Error("LoadAttributes: %v", err) 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 } @@ -532,8 +708,8 @@ func (n *actionsNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Contex Notify(ctx) } -func (n *actionsNotifier) NotifyNewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { - ctx = withMethod(ctx, "NotifyNewWikiPage") +func (n *actionsNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { + ctx = withMethod(ctx, "NewWikiPage") newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{ Action: api.HookWikiCreated, @@ -544,8 +720,8 @@ func (n *actionsNotifier) NotifyNewWikiPage(ctx context.Context, doer *user_mode }).Notify(ctx) } -func (n *actionsNotifier) NotifyEditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { - ctx = withMethod(ctx, "NotifyEditWikiPage") +func (n *actionsNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { + ctx = withMethod(ctx, "EditWikiPage") newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{ Action: api.HookWikiEdited, @@ -556,8 +732,8 @@ func (n *actionsNotifier) NotifyEditWikiPage(ctx context.Context, doer *user_mod }).Notify(ctx) } -func (n *actionsNotifier) NotifyDeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { - ctx = withMethod(ctx, "NotifyDeleteWikiPage") +func (n *actionsNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { + ctx = withMethod(ctx, "DeleteWikiPage") newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{ Action: api.HookWikiDeleted, @@ -566,3 +742,15 @@ func (n *actionsNotifier) NotifyDeleteWikiPage(ctx context.Context, doer *user_m 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 ff00e48c64..8c98f56af5 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 0000000000..60f6650905 --- /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 0000000000..a98de7b788 --- /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 87131e0aab..e4e56e5122 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -10,6 +10,8 @@ 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" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -44,21 +46,43 @@ func startTasks(ctx context.Context) error { return fmt.Errorf("find specs: %w", err) } + if err := specs.LoadRepos(ctx); err != nil { + return fmt.Errorf("LoadRepos: %w", err) + } + // Loop through each spec and create a schedule task for it for _, row := range specs { // 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) } } + 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 + } + if err := CreateScheduleTask(ctx, row.Schedule); err != nil { log.Error("CreateScheduleTask: %v", err) return err @@ -103,6 +127,8 @@ 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, } @@ -117,19 +143,6 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) return err } - // Retrieve the jobs for the newly created action run - jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: run.ID}) - if err != nil { - return err - } - - // Create commit statuses for each job - for _, job := range jobs { - if err := createCommitStatus(ctx, job); err != nil { - return err - } - } - // Return nil if no errors occurred return nil } diff --git a/services/actions/variables.go b/services/actions/variables.go new file mode 100644 index 0000000000..8dde9c4af5 --- /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 ac8c52d19c..eb3bafa906 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" @@ -14,31 +15,28 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/private" + 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)) + 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) - ownerName := repo.OwnerName - repoName := repo.Name - - topicBranch = opts.GitPushOptions["topic"] - _, forcePush = opts.GitPushOptions["force-push"] + 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 +77,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 +84,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 +94,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) + } + + 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) + } } // 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] - } - description = opts.GitPushOptions["description"] + if title == "" { + title = strings.Split(commit.CommitMessage, "\n")[0] } - - pusher, err := user_model.GetUserByID(ctx, opts.UserID) - if err != nil { - return nil, fmt.Errorf("Failed to get user. Error: %w", err) + if description == "" { + _, description, _ = strings.Cut(commit.CommitMessage, "\n\n") + } + if description == "" { + description = title } prIssue := &issues_model.Issue{ @@ -151,7 +147,7 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. results = append(results, private.HookProcReceiveRefResult{ Ref: pr.GetGitRefName(), OriginalRef: opts.RefFullNames[i], - OldOID: git.EmptySHA, + OldOID: objectFormat.EmptyObjectID().String(), NewOID: opts.NewCommitIDs[i], }) continue @@ -159,12 +155,12 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git. // 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 +174,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,23 +192,19 @@ 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 { - notification.NotifyPullRequestPushCommits(ctx, pusher, pr, comment) + notify_service.PullRequestPushCommits(ctx, pusher, pr, comment) } - notification.NotifyPullRequestSynchronized(ctx, pusher, pr) + notify_service.PullRequestSynchronized(ctx, pusher, pr) isForcePush := comment != nil && comment.IsForcePush results = append(results, private.HookProcReceiveRefResult{ @@ -237,7 +231,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 f5ca54b723..324688c534 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 3fa88340fd..3505b26f69 100644 --- a/services/asymkey/main_test.go +++ b/services/asymkey/main_test.go @@ -4,14 +4,14 @@ package asymkey import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" ) 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 252277e1bc..2f5d76a293 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 } @@ -151,7 +156,7 @@ Loop: return false, "", nil, &ErrWontSign{pubkey} } case twofa: - twofaModel, err := auth.GetTwoFactorByUID(u.ID) + twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { 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 } @@ -187,7 +196,7 @@ Loop: return false, "", nil, &ErrWontSign{pubkey} } case twofa: - twofaModel, err := auth.GetTwoFactorByUID(u.ID) + twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { 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 } @@ -240,7 +252,7 @@ Loop: return false, "", nil, &ErrWontSign{pubkey} } case twofa: - twofaModel, err := auth.GetTwoFactorByUID(u.ID) + twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { 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 } @@ -302,7 +317,7 @@ Loop: return false, "", nil, &ErrWontSign{pubkey} } case twofa: - twofaModel, err := auth.GetTwoFactorByUID(u.ID) + twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { return false, "", nil, err } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index 0809458107..da57059d4b 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 0000000000..5caa5bbfb6 --- /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 592196c255..2838bb5fc7 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 0000000000..5ed5cfa782 --- /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 32c31a4332..fbd5d13ab2 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 3e7df0cee0..0fd51e4fa5 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 1b9af34427..142bcfe629 100644 --- a/services/attachment/attachment_test.go +++ b/services/attachment/attachment_test.go @@ -13,13 +13,13 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + _ "code.gitea.io/gitea/models/actions" + "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestUploadAttachment(t *testing.T) { @@ -32,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 c7fdc56cbe..a2523a2452 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 f4e3cdf0d3..3adaa28664 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 0000000000..6b59238c98 --- /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 0000000000..23c8d17e59 --- /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 36480568ff..1184d12d1c 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -15,13 +15,13 @@ 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" ) // Ensure the struct implements the interface. var ( _ Method = &Basic{} - _ Named = &Basic{} ) // BasicMethodName is the constant name of the basic authentication method @@ -43,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 } @@ -72,7 +72,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } // check oauth2 token - uid := CheckOAuthAccessToken(authToken) + uid := CheckOAuthAccessToken(req.Context(), authToken) if uid != 0 { log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid) @@ -87,7 +87,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } // check personal access token - token, err := auth_model.GetAccessTokenBySHA(authToken) + token, err := auth_model.GetAccessTokenBySHA(req.Context(), authToken) if err == nil { log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid) u, err := user_model.GetUserByID(req.Context(), token.UID) @@ -97,7 +97,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } token.UpdatedUnix = timeutil.TimeStampNow() - if err = auth_model.UpdateAccessToken(token); err != nil { + if err = auth_model.UpdateAccessToken(req.Context(), token); err != nil { log.Error("UpdateAccessToken: %v", err) } @@ -124,7 +124,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } log.Trace("Basic Authorization: Attempting SignIn for %s", uname) - u, source, err := UserSignIn(uname, passwd) + u, source, err := UserSignIn(req.Context(), uname, passwd) if err != nil { if !user_model.IsErrUserNotExist(err) { log.Error("UserSignIn: %v", err) @@ -132,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/group.go b/services/auth/group.go index 7193dfcf49..aecf43cb24 100644 --- a/services/auth/group.go +++ b/services/auth/group.go @@ -5,7 +5,6 @@ package auth import ( "net/http" - "reflect" "strings" user_model "code.gitea.io/gitea/models/user" @@ -37,21 +36,16 @@ func (b *Group) Add(method Method) { func (b *Group) Name() string { names := make([]string, 0, len(b.methods)) for _, m := range b.methods { - if n, ok := m.(Named); ok { - names = append(names, n.Name()) - } else { - names = append(names, reflect.TypeOf(m).Elem().Name()) - } + names = append(names, m.Name()) } return strings.Join(names, ",") } -// Verify extracts and validates func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) { // Try to sign in with each of the enabled plugins var retErr error - for _, ssoMethod := range b.methods { - user, err := ssoMethod.Verify(req, w, store, sess) + for _, m := range b.methods { + user, err := m.Verify(req, w, store, sess) if err != nil { if retErr == nil { retErr = err @@ -67,9 +61,7 @@ func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore // Return the user and ignore any error returned by previous methods. if user != nil { if store.GetData()["AuthedMethod"] == nil { - if named, ok := ssoMethod.(Named); ok { - store.GetData()["AuthedMethod"] = named.Name() - } + store.GetData()["AuthedMethod"] = m.Name() } return user, nil } diff --git a/services/auth/httpsign.go b/services/auth/httpsign.go index 4d52315381..b604349f80 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" @@ -23,7 +24,6 @@ import ( // Ensure the struct implements the interface. var ( _ Method = &HTTPSign{} - _ Named = &HTTPSign{} ) // HTTPSign implements the Auth interface and authenticates requests (API requests @@ -93,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/interface.go b/services/auth/interface.go index 508291fa43..ece28af12d 100644 --- a/services/auth/interface.go +++ b/services/auth/interface.go @@ -27,16 +27,13 @@ type Method interface { // Second argument returns err if verification fails, otherwise // First return argument returns nil if no matched verification condition Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) -} -// Named represents a named thing -type Named interface { Name() string } // PasswordAuthenticator represents a source of authentication type PasswordAuthenticator interface { - Authenticate(user *user_model.User, login, password string) (*user_model.User, error) + Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) } // LocalTwoFASkipper represents a source of authentication that can skip local 2fa diff --git a/services/auth/main_test.go b/services/auth/main_test.go new file mode 100644 index 0000000000..b81c39a1f2 --- /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/middleware.go b/services/auth/middleware.go deleted file mode 100644 index d1955a4c90..0000000000 --- a/services/auth/middleware.go +++ /dev/null @@ -1,255 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package auth - -import ( - "net/http" - "strings" - - "code.gitea.io/gitea/models/auth" - 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/modules/web/middleware" -) - -// Auth is a middleware to authenticate a web user -func Auth(authMethod Method) func(*context.Context) { - return func(ctx *context.Context) { - ar, err := authShared(ctx.Base, ctx.Session, authMethod) - if err != nil { - log.Error("Failed to verify user: %v", err) - ctx.Error(http.StatusUnauthorized, "Verify") - return - } - ctx.Doer = ar.Doer - ctx.IsSigned = ar.Doer != nil - ctx.IsBasicAuth = ar.IsBasicAuth - if ctx.Doer == nil { - // ensure the session uid is deleted - _ = ctx.Session.Delete("uid") - } - } -} - -// APIAuth is a middleware to authenticate an api user -func APIAuth(authMethod Method) func(*context.APIContext) { - return func(ctx *context.APIContext) { - ar, err := authShared(ctx.Base, nil, authMethod) - if err != nil { - ctx.Error(http.StatusUnauthorized, "APIAuth", err) - return - } - ctx.Doer = ar.Doer - ctx.IsSigned = ar.Doer != nil - ctx.IsBasicAuth = ar.IsBasicAuth - } -} - -type authResult struct { - Doer *user_model.User - IsBasicAuth bool -} - -func authShared(ctx *context.Base, sessionStore SessionStore, authMethod Method) (ar authResult, err error) { - ar.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, sessionStore) - if err != nil { - return ar, err - } - if ar.Doer != nil { - if ctx.Locale.Language() != ar.Doer.Language { - ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) - } - ar.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == BasicMethodName - - ctx.Data["IsSigned"] = true - ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer - ctx.Data["SignedUserID"] = ar.Doer.ID - ctx.Data["IsAdmin"] = ar.Doer.IsAdmin - } else { - ctx.Data["SignedUserID"] = int64(0) - } - return ar, nil -} - -// VerifyOptions contains required or check options -type VerifyOptions struct { - SignInRequired bool - SignOutRequired bool - AdminRequired bool - DisableCSRF bool -} - -// VerifyAuthWithOptions checks authentication according to options -func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { - return func(ctx *context.Context) { - // Check prohibit login users. - if ctx.IsSigned { - if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { - ctx.Data["Title"] = ctx.Tr("auth.active_your_account") - ctx.HTML(http.StatusOK, "user/auth/activate") - return - } - if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { - log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) - ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") - ctx.HTML(http.StatusOK, "user/auth/prohibit_login") - return - } - - 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")) - return - } - ctx.Data["Title"] = ctx.Tr("auth.must_change_password") - ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" - if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) - } - ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") - return - } - } else if ctx.Req.URL.Path == "/user/settings/change_password" { - // make sure that the form cannot be accessed by users who don't need this - ctx.Redirect(setting.AppSubURL + "/") - return - } - } - - // Redirect to dashboard if user tries to visit any non-login page. - if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { - ctx.Redirect(setting.AppSubURL + "/") - return - } - - if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" { - ctx.Csrf.Validate(ctx) - if ctx.Written() { - return - } - } - - if options.SignInRequired { - if !ctx.IsSigned { - if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) - } - ctx.Redirect(setting.AppSubURL + "/user/login") - return - } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { - ctx.Data["Title"] = ctx.Tr("auth.active_your_account") - ctx.HTML(http.StatusOK, "user/auth/activate") - return - } - } - - // 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 { - if ctx.Req.URL.Path != "/user/events" { - middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) - } - ctx.Redirect(setting.AppSubURL + "/user/login") - return - } - - if options.AdminRequired { - if !ctx.Doer.IsAdmin { - ctx.Error(http.StatusForbidden) - return - } - ctx.Data["PageIsAdmin"] = true - } - } -} - -// VerifyAuthWithOptionsAPI checks authentication according to options -func VerifyAuthWithOptionsAPI(options *VerifyOptions) func(ctx *context.APIContext) { - return func(ctx *context.APIContext) { - // Check prohibit login users. - if ctx.IsSigned { - if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { - ctx.Data["Title"] = ctx.Tr("auth.active_your_account") - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "This account is not activated.", - }) - return - } - if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { - log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) - ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "This account is prohibited from signing in, please contact your site administrator.", - }) - return - } - - if ctx.Doer.MustChangePassword { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password", - }) - return - } - } - - // Redirect to dashboard if user tries to visit any non-login page. - if options.SignOutRequired && ctx.IsSigned && ctx.Req.URL.RequestURI() != "/" { - ctx.Redirect(setting.AppSubURL + "/") - return - } - - if options.SignInRequired { - if !ctx.IsSigned { - // Restrict API calls with error message. - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "Only signed in user is allowed to call APIs.", - }) - return - } else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { - ctx.Data["Title"] = ctx.Tr("auth.active_your_account") - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "This account is not activated.", - }) - return - } - if ctx.IsSigned && ctx.IsBasicAuth { - if skip, ok := ctx.Data["SkipLocalTwoFA"]; ok && skip.(bool) { - return // Skip 2FA - } - twofa, err := auth.GetTwoFactorByUID(ctx.Doer.ID) - if err != nil { - if auth.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 { - if !ctx.Doer.IsAdmin { - ctx.JSON(http.StatusForbidden, map[string]string{ - "message": "You have no permission to request for this.", - }) - return - } - } - } -} diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 0dd7a12d2c..46d8510143 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -5,15 +5,16 @@ package auth import ( + "context" "net/http" "strings" "time" actions_model "code.gitea.io/gitea/models/actions" 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/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" @@ -22,11 +23,10 @@ import ( // Ensure the struct implements the interface. var ( _ Method = &OAuth2{} - _ Named = &OAuth2{} ) // CheckOAuthAccessToken returns uid of user from oauth token -func CheckOAuthAccessToken(accessToken string) int64 { +func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 { // JWT tokens require a "." if !strings.Contains(accessToken, ".") { return 0 @@ -37,7 +37,7 @@ func CheckOAuthAccessToken(accessToken string) int64 { return 0 } var grant *auth_model.OAuth2Grant - if grant, err = auth_model.GetOAuth2GrantByID(db.DefaultContext, token.GrantID); err != nil || grant == nil { + if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil { return 0 } if token.Type != oauth2.TypeAccessToken { @@ -63,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) @@ -84,21 +89,21 @@ func parseToken(req *http.Request) (string, bool) { // userIDFromToken returns the user id corresponding to the OAuth token. // It will set 'IsApiToken' to true if the token is an API token and // set 'ApiTokenScope' to the scope of the access token -func (o *OAuth2) userIDFromToken(tokenSHA string, store DataStore) int64 { +func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 { // Let's see if token is valid. if strings.Contains(tokenSHA, ".") { - uid := CheckOAuthAccessToken(tokenSHA) + uid := CheckOAuthAccessToken(ctx, tokenSHA) if uid != 0 { store.GetData()["IsApiToken"] = true store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all } return uid } - t, err := auth_model.GetAccessTokenBySHA(tokenSHA) + t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA) if err != nil { if auth_model.IsErrAccessTokenNotExist(err) { // check task token - task, err := actions_model.GetRunningTaskByToken(db.DefaultContext, tokenSHA) + task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA) if err == nil && task != nil { log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID) @@ -113,7 +118,7 @@ func (o *OAuth2) userIDFromToken(tokenSHA string, store DataStore) int64 { return 0 } t.UpdatedUnix = timeutil.TimeStampNow() - if err = auth_model.UpdateAccessToken(t); err != nil { + if err = auth_model.UpdateAccessToken(ctx, t); err != nil { log.Error("UpdateAccessToken: %v", err) } store.GetData()["IsApiToken"] = true @@ -126,7 +131,9 @@ func (o *OAuth2) userIDFromToken(tokenSHA string, store DataStore) int64 { // 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 } @@ -135,7 +142,7 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor return nil, nil } - id := o.userIDFromToken(token, store) + id := o.userIDFromToken(req.Context(), token, store) if id <= 0 && id != -2 { // -2 means actions, so we need to allow it. return nil, user_model.ErrUserNotExist{} diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index 3574e660b8..b6aeb0aed2 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" @@ -20,7 +20,6 @@ import ( // Ensure the struct implements the interface. var ( _ Method = &ReverseProxy{} - _ Named = &ReverseProxy{} ) // ReverseProxyMethodName is the constant name of the ReverseProxy authentication method @@ -118,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) } @@ -162,10 +161,10 @@ 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(user, &overwriteDefault); err != nil { + if err := user_model.CreateUser(req.Context(), user, &overwriteDefault); err != nil { // FIXME: should I create a system notice? log.Error("CreateUser: %v", err) return nil diff --git a/services/auth/session.go b/services/auth/session.go index c751135738..35d97e42da 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" ) @@ -14,7 +13,6 @@ import ( // Ensure the struct implements the interface. var ( _ Method = &Session{} - _ Named = &Session{} ) // Session checks if there is a user uid stored in the session and returns the user @@ -30,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 1095b27fe2..e116a088e0 100644 --- a/services/auth/signin.go +++ b/services/auth/signin.go @@ -4,12 +4,14 @@ package auth import ( + "context" "strings" "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/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/smtp" @@ -20,14 +22,14 @@ import ( ) // UserSignIn validates user name and password. -func UserSignIn(username, password string) (*user_model.User, *auth.Source, error) { +func UserSignIn(ctx context.Context, username, password string) (*user_model.User, *auth.Source, error) { var user *user_model.User isEmail := false if strings.Contains(username, "@") { isEmail = true emailAddress := user_model.EmailAddress{LowerEmail: strings.ToLower(strings.TrimSpace(username))} // check same email - has, err := db.GetEngine(db.DefaultContext).Get(&emailAddress) + has, err := db.GetEngine(ctx).Get(&emailAddress) if err != nil { return nil, nil, err } @@ -49,13 +51,13 @@ func UserSignIn(username, password string) (*user_model.User, *auth.Source, erro } if user != nil { - hasUser, err := user_model.GetUser(user) + hasUser, err := user_model.GetUser(ctx, user) if err != nil { return nil, nil, err } if hasUser { - source, err := auth.GetSourceByID(user.LoginSource) + source, err := auth.GetSourceByID(ctx, user.LoginSource) if err != nil { return nil, nil, err } @@ -69,7 +71,7 @@ func UserSignIn(username, password string) (*user_model.User, *auth.Source, erro return nil, nil, smtp.ErrUnsupportedLoginType } - user, err := authenticator.Authenticate(user, user.LoginName, password) + user, err := authenticator.Authenticate(ctx, user, user.LoginName, password) if err != nil { return nil, nil, err } @@ -84,7 +86,9 @@ func UserSignIn(username, password string) (*user_model.User, *auth.Source, erro } } - sources, err := auth.AllActiveSources() + sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ + IsActive: optional.Some(true), + }) if err != nil { return nil, nil, err } @@ -100,7 +104,7 @@ func UserSignIn(username, password string) (*user_model.User, *auth.Source, erro continue } - authUser, err := authenticator.Authenticate(nil, username, password) + authUser, err := authenticator.Authenticate(ctx, nil, username, password) if err == nil { if !authUser.ProhibitLogin { diff --git a/services/auth/source.go b/services/auth/source.go index aae3a78102..69b71a6dea 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/authenticate.go b/services/auth/source/db/authenticate.go index 34a0459149..8160141863 100644 --- a/services/auth/source/db/authenticate.go +++ b/services/auth/source/db/authenticate.go @@ -4,9 +4,9 @@ package db import ( + "context" "fmt" - "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" @@ -43,7 +43,7 @@ func (err ErrUserPasswordInvalid) Unwrap() error { } // Authenticate authenticates the provided user against the DB -func Authenticate(user *user_model.User, login, password string) (*user_model.User, error) { +func Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) { if user == nil { return nil, user_model.ErrUserNotExist{Name: login} } @@ -61,7 +61,7 @@ func Authenticate(user *user_model.User, login, password string) (*user_model.Us if err := user.SetPassword(password); err != nil { return nil, err } - if err := user_model.UpdateUserCols(db.DefaultContext, user, "passwd", "passwd_hash_algo", "salt"); err != nil { + if err := user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil { return nil, err } } diff --git a/services/auth/source/db/source.go b/services/auth/source/db/source.go index 3f4113c790..bb2270cbd6 100644 --- a/services/auth/source/db/source.go +++ b/services/auth/source/db/source.go @@ -4,6 +4,8 @@ package db import ( + "context" + "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" ) @@ -16,15 +18,15 @@ 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 } // Authenticate queries if login/password is valid against the PAM, // and create a local user if success when enabled. -func (source *Source) Authenticate(user *user_model.User, login, password string) (*user_model.User, error) { - return Authenticate(user, login, password) +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) { + return Authenticate(ctx, user, login, password) } func init() { diff --git a/services/auth/source/ldap/README.md b/services/auth/source/ldap/README.md index 568bc78275..34c811703f 100644 --- a/services/auth/source/ldap/README.md +++ b/services/auth/source/ldap/README.md @@ -114,12 +114,13 @@ share the following fields: * Example: (|(cn=gitea_users)(cn=admins)) * User Attribute in Group (optional) - * Which user LDAP attribute is listed in the group. - * Example: uid + * The user attribute that is used to reference a user in the group object. + * Example: uid if the group objects contains a member: bender and the user object contains a uid: bender. + * Example: dn if the group object contains a member: uid=bender,ou=users,dc=planetexpress,dc=com. * Group Attribute for User (optional) - * Which group LDAP attribute contains an array above user attribute names. - * Example: memberUid + * The attribute of the group object that lists/contains the group members. + * Example: memberUid or member * Team group map (optional) * Automatically add users to Organization teams, depending on LDAP group memberships. diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 3f3219adb9..6ebd3ea50a 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -4,22 +4,23 @@ package ldap import ( + "context" "fmt" "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/db" 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" ) // Authenticate queries if login/password is valid against the LDAP directory pool, // and create a local user if success when enabled. -func (source *Source) Authenticate(user *user_model.User, userName, password string) (*user_model.User, error) { +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) { loginName := userName if user != nil { loginName = user.LoginName @@ -29,34 +30,37 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str // 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 - if isExist, err := user_model.IsUserExist(db.DefaultContext, 0, sr.Username); err != nil { + if isExist, err := user_model.IsUserExist(ctx, 0, sr.Username); err != nil { return nil, err } else if isExist { if user == nil { - user, err = user_model.GetUserByName(db.DefaultContext, sr.Username) + user, err = user_model.GetUserByName(ctx, sr.Username) if err != nil { return nil, err } } 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(db.DefaultContext, 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(user *user_model.User, userName, password str } 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,22 +85,22 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str 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(user, overwriteDefault) + err := user_model.CreateUser(ctx, user, overwriteDefault) if err != nil { 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 } } @@ -116,7 +111,7 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str if err != nil { return user, err } - if err := source_service.SyncGroupsToTeams(db.DefaultContext, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { return user, err } } diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 43ee32c84b..0c9491cd09 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -13,8 +13,10 @@ import ( "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" 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" ) @@ -27,7 +29,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { var sshKeysNeedUpdate bool // Find all users with this login type - FIXME: Should this be an iterator? - users, err := user_model.GetUsersBySource(source.authSource) + users, err := user_model.GetUsersBySource(ctx, source.authSource) if err != nil { log.Error("SyncExternalUsers: %v", err) return err @@ -41,7 +43,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { usernameUsers := make(map[string]*user_model.User, len(users)) mailUsers := make(map[string]*user_model.User, len(users)) - keepActiveUsers := make(map[int64]struct{}) + keepActiveUsers := make(container.Set[int64]) for _, u := range users { usernameUsers[u.LowerName] = u @@ -76,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) } @@ -97,7 +99,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { } if usr != nil { - keepActiveUsers[usr.ID] = struct{}{} + keepActiveUsers.Add(usr.ID) } else if len(su.Username) == 0 { // we cannot create the user if su.Username is empty continue @@ -123,28 +125,28 @@ 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(usr, overwriteDefault) + err = user_model.CreateUser(ctx, usr, overwriteDefault) if err != nil { log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.authSource.Name, su.Username, err) } 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 } @@ -157,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) } } } @@ -192,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) } @@ -208,15 +212,16 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { // Deactivate users not present in LDAP if updateExisting { for _, usr := range users { - if _, ok := keepActiveUsers[usr.ID]; ok { + if keepActiveUsers.Contains(usr.ID) { continue } 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 32fe545c90..5c25681548 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 eca0b8b7e1..070fffe60f 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 7572aa20c0..6ed6c184eb 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 5ba06febaf..9d4ab106e5 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 54530ae8a8..285876d5ac 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/oauth2/source_authenticate.go b/services/auth/source/oauth2/source_authenticate.go index e3e2a9e192..bbda35dee0 100644 --- a/services/auth/source/oauth2/source_authenticate.go +++ b/services/auth/source/oauth2/source_authenticate.go @@ -4,13 +4,15 @@ package oauth2 import ( + "context" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/services/auth/source/db" ) // Authenticate falls back to the db authenticator -func (source *Source) Authenticate(user *user_model.User, login, password string) (*user_model.User, error) { - return db.Authenticate(user, login, password) +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) { + return db.Authenticate(ctx, user, login, password) } // NB: Oauth2 does not implement LocalTwoFASkipper for password authentication diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go index e3a74e495c..addd1bd2c9 100644 --- a/services/auth/source/pam/source_authenticate.go +++ b/services/auth/source/pam/source_authenticate.go @@ -4,21 +4,22 @@ package pam import ( + "context" "fmt" "strings" "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" ) // Authenticate queries if login/password is valid against the PAM, // and create a local user if success when enabled. -func (source *Source) Authenticate(user *user_model.User, userName, password string) (*user_model.User, error) { +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) { pamLogin, err := pam.Auth(source.ServiceName, userName, password) if err != nil { if strings.Contains(err.Error(), "Authentication failure") { @@ -59,10 +60,10 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str 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(user, overwriteDefault); err != nil { + if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { return user, err } diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go index 7d7d1aa8b6..1f0a61c789 100644 --- a/services/auth/source/smtp/source_authenticate.go +++ b/services/auth/source/smtp/source_authenticate.go @@ -4,6 +4,7 @@ package smtp import ( + "context" "errors" "net/smtp" "net/textproto" @@ -11,12 +12,13 @@ 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" ) // Authenticate queries if the provided login/password is authenticates against the SMTP server // Users will be autoregistered as required -func (source *Source) Authenticate(user *user_model.User, userName, password string) (*user_model.User, error) { +func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) { // Verify allowed domains. if len(source.AllowedDomains) > 0 { idx := strings.Index(userName, "@") @@ -74,10 +76,10 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str LoginName: userName, } overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsActive: util.OptionalBoolTrue, + IsActive: optional.Some(true), } - if err := user_model.CreateUser(user, overwriteDefault); err != nil { + if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { return user, err } diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index e42f60bde2..05293f202f 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(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(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 78% rename from services/auth/sspi_windows.go rename to services/auth/sspi.go index eabfd5fa41..64a127e97a 100644 --- a/services/auth/sspi_windows.go +++ b/services/auth/sspi.go @@ -4,46 +4,49 @@ package auth import ( + "context" "errors" "net/http" "strings" "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{} - _ Named = &SSPI{} ) // 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 @@ -114,7 +112,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, log.Error("User '%s' not found", username) return nil, nil } - user, err = s.newUser(username, cfg) + user, err = s.newUser(req.Context(), username, cfg) if err != nil { log.Error("CreateUser: %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 } @@ -162,23 +163,20 @@ func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) { // newUser creates a new user object for the purpose of automatic registration // and populates its name and email with the information present in request headers. -func (s *SSPI) newUser(username string, cfg *sspi.Source) (*user_model.User, error) { +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(user, overwriteDefault); err != nil { + if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil { return nil, err } diff --git a/services/auth/sspiauth_posix.go b/services/auth/sspiauth_posix.go new file mode 100644 index 0000000000..49b0ed4a52 --- /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 0000000000..093caaed33 --- /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 e42e8a51a7..7562ac812b 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 bf713c4431..bd427bef9f 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 58532b883d..b18a206b5e 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.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 8df1dde866..62fb743714 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 0000000000..823f20e00b --- /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 100% rename from modules/context/captcha.go rename to services/context/captcha.go 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 47ad310b09..4b318f7e33 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 0000000000..b6f8dadb56 --- /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 5729865561..d7fd18acac 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 ba90fc170a..7878d409ca 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 0000000000..984593398d --- /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 2d7cf5185c..018b76de43 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 @@ -250,10 +252,24 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { return } } + ctx.Data["ContextUser"] = ctx.ContextUser 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 c0813fb2da..c452c657e7 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 68237c630c..fb2ef699ce 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 f5c56cf833..56e9fada0e 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) @@ -471,6 +458,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { } ctx.Repo.Owner = owner ctx.ContextUser = owner + ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["Username"] = ctx.Repo.Owner.Name // redirect link to wiki @@ -494,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) { @@ -535,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 @@ -559,8 +552,9 @@ 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.Doer, ctx.Repo.Repository) + canSignedUserFork, err := repo_module.CanUserForkRepo(ctx, ctx.Doer, ctx.Repo.Repository) if err != nil { ctx.ServerError("CanUserForkRepo", err) return nil @@ -597,7 +591,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { } if ctx.IsSigned { - ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx.Doer.ID, repo.ID) + ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID) } @@ -626,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) @@ -638,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 { @@ -662,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 @@ -689,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. @@ -702,18 +694,18 @@ func RepoAssignment(ctx *Context) context.CancelFunc { // People who have push access or have forked repository can propose a new pull request. canPush := ctx.Repo.CanWrite(unit_model.TypeCode) || - (ctx.IsSigned && repo_model.HasForkedRepo(ctx.Doer.ID, ctx.Repo.Repository.ID)) + (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) canCompare := false // 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 @@ -739,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) } } @@ -772,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 { @@ -820,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] } @@ -832,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 } @@ -856,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 { @@ -891,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 @@ -972,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 @@ -982,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 cd10715864..77a7eb9377 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 62d2dc0aa2..4c9cd2928b 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 { @@ -27,12 +26,13 @@ func UserAssignmentWeb() func(ctx *context.Context) { } } ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, errorFn) + ctx.Data["ContextUser"] = ctx.ContextUser } } // 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 { @@ -52,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) { @@ -68,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 53% rename from modules/context/utils.go rename to services/context/utils.go index c0f619aa23..293750fee1 100644 --- a/modules/context/utils.go +++ b/services/context/utils.go @@ -4,29 +4,18 @@ package context import ( - "net/url" "strings" "time" ) // GetQueryBeforeSince return parsed time (unix format) from URL query's before and since func GetQueryBeforeSince(ctx *Base) (before, since int64, err error) { - qCreatedBefore, err := prepareQueryArg(ctx, "before") + before, err = parseFormTime(ctx, "before") if err != nil { return 0, 0, err } - qCreatedSince, err := prepareQueryArg(ctx, "since") - if err != nil { - return 0, 0, err - } - - before, err = parseTime(qCreatedBefore) - if err != nil { - return 0, 0, err - } - - since, err = parseTime(qCreatedSince) + since, err = parseFormTime(ctx, "since") if err != nil { return 0, 0, err } @@ -34,7 +23,8 @@ func GetQueryBeforeSince(ctx *Base) (before, since int64, err error) { } // parseTime parse time and return unix timestamp -func parseTime(value string) (int64, error) { +func parseFormTime(ctx *Base, name string) (int64, error) { + value := strings.TrimSpace(ctx.FormString(name)) if len(value) != 0 { t, err := time.Parse(time.RFC3339, value) if err != nil { @@ -46,10 +36,3 @@ func parseTime(value string) (int64, error) { } return 0, nil } - -// prepareQueryArg unescape and trim a query arg -func prepareQueryArg(ctx *Base, name string) (value string, err error) { - value, err = url.PathUnescape(ctx.FormString(name)) - value = strings.TrimSpace(value) - return value, err -} 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/test/context_tests.go b/services/contexttest/context_tests.go similarity index 76% rename from modules/test/context_tests.go rename to services/contexttest/context_tests.go index 83e6117bcf..3064c56590 100644 --- a/modules/test/context_tests.go +++ b/services/contexttest/context_tests.go @@ -1,28 +1,31 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package test +// Package contexttest provides utilities for testing Web/API contexts with models. +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" - chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" ) @@ -34,14 +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 -// TODO: move this function to other packages, because it depends on "models" package -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,16 +62,16 @@ 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.Flash = &middleware.Flash{Values: url.Values{}} - + 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 } // MockAPIContext mock context for unit tests -// TODO: move this function to other packages, because it depends on "models" package func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptest.ResponseRecorder) { resp := httptest.NewRecorder() req := mockRequest(t, reqPath) @@ -85,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}) @@ -107,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() @@ -123,7 +134,7 @@ func LoadRepoCommit(t *testing.T, ctx gocontext.Context) { } } -// LoadUser load a user into a test context. +// LoadUser load a user into a test context func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) { doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) switch ctx := ctx.(type) { @@ -132,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") } } @@ -142,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 ab36a1c577..4a8f10f7b0 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 a7a777e8bd..ca3ec32a40 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" @@ -107,28 +106,28 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin } // ToBranchProtection convert a ProtectedBranch to api.BranchProtection -func ToBranchProtection(bp *git_model.ProtectedBranch) *api.BranchProtection { - pushWhitelistUsernames, err := user_model.GetUserNamesByIDs(bp.WhitelistUserIDs) +func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch) *api.BranchProtection { + pushWhitelistUsernames, err := user_model.GetUserNamesByIDs(ctx, bp.WhitelistUserIDs) if err != nil { log.Error("GetUserNamesByIDs (WhitelistUserIDs): %v", err) } - mergeWhitelistUsernames, err := user_model.GetUserNamesByIDs(bp.MergeWhitelistUserIDs) + mergeWhitelistUsernames, err := user_model.GetUserNamesByIDs(ctx, bp.MergeWhitelistUserIDs) if err != nil { log.Error("GetUserNamesByIDs (MergeWhitelistUserIDs): %v", err) } - approvalsWhitelistUsernames, err := user_model.GetUserNamesByIDs(bp.ApprovalsWhitelistUserIDs) + approvalsWhitelistUsernames, err := user_model.GetUserNamesByIDs(ctx, bp.ApprovalsWhitelistUserIDs) 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(bp *git_model.ProtectedBranch) *api.BranchProtection { 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 ac15719c1c..e0efcddbcb 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 8c4ef88ebe..73cb5e8c71 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 33fad31d48..c6e06180c8 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" @@ -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() @@ -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 } diff --git a/services/convert/issue_comment.go b/services/convert/issue_comment.go index 1308051e7c..b034a50897 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(), diff --git a/services/convert/main_test.go b/services/convert/main_test.go index 4c8e57bf79..363cc4a97f 100644 --- a/services/convert/main_test.go +++ b/services/convert/main_test.go @@ -4,14 +4,13 @@ package convert import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/actions" ) 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 f7a8e17fd0..249ce2f968 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 3906fa9b38..41063cf399 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 276856594b..b5fca21a3c 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 e4e3097056..6d98121ed5 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" ) @@ -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 5d5d5d883c..29a5ab7466 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 0000000000..6886950280 --- /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 6f77b4932e..39efd304a9 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 1f04843483..767bfdb88d 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 e3f31d08f0..3c5737e371 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 0656307cba..6dad88830a 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 ea1925c26c..f8a7444c49 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_basic.go b/services/cron/tasks_basic.go index 2a213ae515..3869382d22 100644 --- a/services/cron/tasks_basic.go +++ b/services/cron/tasks_basic.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" @@ -156,6 +157,20 @@ func registerCleanupPackages() { }) } +func registerActionsCleanup() { + RegisterTaskFatal("cleanup_actions", &OlderThanConfig{ + BaseConfig: BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@midnight", + }, + OlderThan: 24 * time.Hour, + }, func(ctx context.Context, _ *user_model.User, config Config) error { + realConfig := config.(*OlderThanConfig) + return actions.Cleanup(ctx, realConfig.OlderThan) + }) +} + func initBasicTasks() { if setting.Mirror.Enabled { registerUpdateMirrorTask() @@ -172,4 +187,7 @@ func initBasicTasks() { if setting.Packages.Enabled { registerCleanupPackages() } + if setting.Actions.Enabled { + registerActionsCleanup() + } } diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 48ea87df7f..0018c5facc 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 69052d739c..979371a022 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 e4d85c4a18..8d6fc9cb5e 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 90% rename from modules/doctor/dbconsistency.go rename to services/doctor/dbconsistency.go index 541fc736f4..e2dcb63f33 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" @@ -101,7 +102,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 +152,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 +181,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,6 +202,9 @@ 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"), 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 90% rename from modules/doctor/doctor.go rename to services/doctor/doctor.go index ceee322852..559f8e06da 100644 --- a/modules/doctor/doctor.go +++ b/services/doctor/doctor.go @@ -79,6 +79,7 @@ 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}) @@ -104,20 +105,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 562c78dd76..50d9ac6621 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 96% rename from modules/doctor/fix8312.go rename to services/doctor/fix8312.go index 8de3113663..4fc049873a 100644 --- a/modules/doctor/fix8312.go +++ b/services/doctor/fix8312.go @@ -29,7 +29,7 @@ func fixOwnerTeamCreateOrgRepo(ctx context.Context, logger log.Logger, autofix b return nil } - return models.UpdateTeam(team, false, false) + return models.UpdateTeam(ctx, team, false, false) }, ) if err != nil { 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/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 e79369e581..de460c4190 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 e01c3e109b..9300c3a25c 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 0000000000..6c33426636 --- /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 f338537864..787df27549 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 87% rename from modules/doctor/usertype.go rename to services/doctor/usertype.go index 550e536cbd..ab32b78e62 100644 --- a/modules/doctor/usertype.go +++ b/services/doctor/usertype.go @@ -11,14 +11,14 @@ import ( ) func checkUserType(ctx context.Context, logger log.Logger, autofix bool) error { - count, err := user_model.CountWrongUserType() + count, err := user_model.CountWrongUserType(ctx) if err != nil { logger.Critical("Error: %v whilst counting wrong user types") return err } if count > 0 { if autofix { - if count, err = user_model.FixWrongUserType(); err != nil { + if count, err = user_model.FixWrongUserType(ctx); err != nil { logger.Critical("Error: %v whilst fixing wrong user types") return err } diff --git a/services/externalaccount/link.go b/services/externalaccount/link.go index a19d4c5ab3..d6e2ea7e94 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 87d2e02b48..e2de41da18 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -4,18 +4,20 @@ package externalaccount import ( + "context" "strings" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/auth" + 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/structs" "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 } @@ -41,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 } @@ -62,18 +64,38 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error { } if tp.Name() != "" { - return models.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(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(ctx, tp, externalUserID, userID); err != nil { + return err + } + + if err := repo_model.UpdateReleasesMigrationsByType(ctx, tp, externalUserID, userID); err != nil { + return err + } + + if err := issues_model.UpdateReactionsMigrationsByType(ctx, tp, externalUserID, userID); err != nil { + return err + } + return issues_model.UpdateReviewsMigrationsByType(ctx, tp, externalUserID, userID) } diff --git a/modules/notification/action/action.go b/services/feed/action.go similarity index 79% rename from modules/notification/action/action.go rename to services/feed/action.go index 1d759e4923..83daaa1438 100644 --- a/modules/notification/action/action.go +++ b/services/feed/action.go @@ -1,7 +1,7 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package action +package feed import ( "context" @@ -16,23 +16,29 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" ) type actionNotifier struct { - base.NullNotifier + notify_service.NullNotifier } -var _ base.Notifier = &actionNotifier{} +var _ notify_service.Notifier = &actionNotifier{} + +func Init() error { + notify_service.RegisterNotifier(NewNotifier()) + + return nil +} // NewNotifier create a new actionNotifier notifier -func NewNotifier() base.Notifier { +func NewNotifier() notify_service.Notifier { return &actionNotifier{} } -func (a *actionNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { +func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { if err := issue.LoadPoster(ctx); err != nil { log.Error("issue.LoadPoster: %v", err) return @@ -56,8 +62,8 @@ func (a *actionNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model } } -// NotifyIssueChangeStatus notifies close or reopen issue to notifiers -func (a *actionNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { +// IssueChangeStatus notifies close or reopen issue to notifiers +func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { // Compose comment action, could be plain comment, close or reopen issue/pull request. // This object will be used to notify watchers in the end of function. act := &activities_model.Action{ @@ -89,8 +95,8 @@ func (a *actionNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user } } -// NotifyCreateIssueComment notifies comment on an issue to notifiers -func (a *actionNotifier) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, +// CreateIssueComment notifies comment on an issue to notifiers +func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { act := &activities_model.Action{ @@ -125,7 +131,7 @@ func (a *actionNotifier) NotifyCreateIssueComment(ctx context.Context, doer *use } } -func (a *actionNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { +func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { if err := pull.LoadIssue(ctx); err != nil { log.Error("pull.LoadIssue: %v", err) return @@ -152,7 +158,7 @@ func (a *actionNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_ } } -func (a *actionNotifier) NotifyRenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) { +func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -166,7 +172,7 @@ func (a *actionNotifier) NotifyRenameRepository(ctx context.Context, doer *user_ } } -func (a *actionNotifier) NotifyTransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) { +func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -180,7 +186,7 @@ func (a *actionNotifier) NotifyTransferRepository(ctx context.Context, doer *use } } -func (a *actionNotifier) NotifyCreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -193,7 +199,7 @@ func (a *actionNotifier) NotifyCreateRepository(ctx context.Context, doer, u *us } } -func (a *actionNotifier) NotifyForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { +func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -206,7 +212,7 @@ func (a *actionNotifier) NotifyForkRepository(ctx context.Context, doer *user_mo } } -func (a *actionNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { +func (a *actionNotifier) 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("LoadReviewer '%d/%d': %v", review.ID, review.ReviewerID, err) return @@ -259,12 +265,12 @@ func (a *actionNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues 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) } } -func (*actionNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -278,7 +284,7 @@ func (*actionNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_mo } } -func (*actionNotifier) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: doer.ID, ActUser: doer, @@ -312,7 +318,7 @@ func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_ } } -func (a *actionNotifier) NotifyPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { data, err := json.Marshal(commits) if err != nil { log.Error("Marshal: %v", err) @@ -345,10 +351,10 @@ func (a *actionNotifier) NotifyPushCommits(ctx context.Context, pusher *user_mod } } -func (a *actionNotifier) NotifyCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { +func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { opType := activities_model.ActionCommitRepo if refFullName.IsTag() { - // has sent same action in `NotifyPushCommits`, so skip it. + // has sent same action in `PushCommits`, so skip it. return } if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ @@ -364,10 +370,10 @@ func (a *actionNotifier) NotifyCreateRef(ctx context.Context, doer *user_model.U } } -func (a *actionNotifier) NotifyDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { +func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { opType := activities_model.ActionDeleteBranch if refFullName.IsTag() { - // has sent same action in `NotifyPushCommits`, so skip it. + // has sent same action in `PushCommits`, so skip it. return } if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ @@ -383,7 +389,7 @@ func (a *actionNotifier) NotifyDeleteRef(ctx context.Context, doer *user_model.U } } -func (a *actionNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { data, err := json.Marshal(commits) if err != nil { log.Error("json.Marshal: %v", err) @@ -404,7 +410,7 @@ func (a *actionNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user } } -func (a *actionNotifier) NotifySyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { +func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: repo.OwnerID, ActUser: repo.MustOwner(ctx), @@ -418,7 +424,7 @@ func (a *actionNotifier) NotifySyncCreateRef(ctx context.Context, doer *user_mod } } -func (a *actionNotifier) NotifySyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { +func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{ ActUserID: repo.OwnerID, ActUser: repo.MustOwner(ctx), @@ -432,7 +438,7 @@ func (a *actionNotifier) NotifySyncDeleteRef(ctx context.Context, doer *user_mod } } -func (a *actionNotifier) NotifyNewRelease(ctx context.Context, rel *repo_model.Release) { +func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { if err := rel.LoadAttributes(ctx); err != nil { log.Error("LoadAttributes: %v", err) return diff --git a/modules/notification/action/action_test.go b/services/feed/action_test.go similarity index 85% rename from modules/notification/action/action_test.go rename to services/feed/action_test.go index 05ce70f388..e1b071d8f6 100644 --- a/modules/notification/action/action_test.go +++ b/services/feed/action_test.go @@ -1,10 +1,9 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package action +package feed import ( - "path/filepath" "strings" "testing" @@ -14,13 +13,13 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + _ "code.gitea.io/gitea/models/actions" + "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } func TestRenameRepoAction(t *testing.T) { @@ -46,7 +45,7 @@ func TestRenameRepoAction(t *testing.T) { } unittest.AssertNotExistsBean(t, actionBean) - NewNotifier().NotifyRenameRepository(db.DefaultContext, user, repo, oldRepoName) + NewNotifier().RenameRepository(db.DefaultContext, user, repo, oldRepoName) unittest.AssertExistsAndLoadBean(t, actionBean) unittest.CheckConsistencyFor(t, &activities_model.Action{}) diff --git a/services/forms/admin.go b/services/forms/admin.go index 4b3cacc606..81276f8f46 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 25acbbb99e..c9f3182b3a 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 6e2d787516..3677fcf429 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 2f08dfe9f4..cc940d42d3 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 5deb0ae463..42e6c85c37 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 b36c8cc9b6..e45a2a1695 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 4dd99f9e32..0135684737 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 6d16cfce49..6abfc66fc2 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 1f5abf94ee..e2e6c208f7 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -10,13 +10,13 @@ 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/web/middleware" + "code.gitea.io/gitea/services/context" "gitea.com/go-chi/binding" - "github.com/gobwas/glob" ) // InstallForm form for installation page @@ -103,40 +103,13 @@ func (f *RegisterForm) Validate(req *http.Request, errs binding.Errors) binding. return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } -// IsEmailDomainListed checks whether the domain of an email address -// matches a list of domains -func IsEmailDomainListed(globs []glob.Glob, email string) bool { - if len(globs) == 0 { - return false - } - - n := strings.LastIndex(email, "@") - if n <= 0 { - return false - } - - domain := strings.ToLower(email[n+1:]) - - for _, g := range globs { - if g.Match(domain) { - return true - } - } - - return false -} - // IsEmailDomainAllowed validates that the email address // provided by the user matches what has been configured . // The email is marked as allowed if it matches any of the // 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 !IsEmailDomainListed(setting.Service.EmailDomainBlockList, f.Email) - } - - return IsEmailDomainListed(setting.Service.EmailDomainAllowList, f.Email) + return user_model.IsEmailDomainAllowed(f.Email) } // MustChangePasswordForm form for updating your password after account creation @@ -388,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 } @@ -472,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 d8137a8d13..ca1c77e320 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 03e629a553..c21fddf478 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.go b/services/gitdiff/csv.go index 5781d7e909..8db73c56a3 100644 --- a/services/gitdiff/csv.go +++ b/services/gitdiff/csv.go @@ -7,8 +7,6 @@ import ( "encoding/csv" "errors" "io" - - "code.gitea.io/gitea/modules/util" ) const ( @@ -396,7 +394,7 @@ func tryMapColumnsByContent(baseCSVReader *csvReader, base2HeadColMap []int, hea headStart := 0 for base2HeadColMap[i] == unmappedColumn && headStart < len(head2BaseColMap) { if head2BaseColMap[headStart] == unmappedColumn { - rows := util.Min(maxRowsToInspect, util.Max(0, util.Min(len(baseCSVReader.buffer), len(headCSVReader.buffer))-1)) + rows := min(maxRowsToInspect, max(0, min(len(baseCSVReader.buffer), len(headCSVReader.buffer))-1)) same := 0 for j := 1; j <= rows; j++ { baseCell, baseErr := getCell(baseCSVReader.buffer[j], i) diff --git a/services/gitdiff/csv_test.go b/services/gitdiff/csv_test.go index ac53e2d1ef..c006a7c2bd 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 4cc093e65d..b05c210a0c 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 e270e46fd4..adcac355a7 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 f1e2b1d3cb..35d4844550 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 a5ac274b8f..cd9dcd8cd6 100644 --- a/services/gitdiff/main_test.go +++ b/services/gitdiff/main_test.go @@ -4,16 +4,15 @@ package gitdiff import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } diff --git a/services/indexer/indexer.go b/services/indexer/indexer.go new file mode 100644 index 0000000000..38dd012a51 --- /dev/null +++ b/services/indexer/indexer.go @@ -0,0 +1,20 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package indexer + +import ( + code_indexer "code.gitea.io/gitea/modules/indexer/code" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + stats_indexer "code.gitea.io/gitea/modules/indexer/stats" + notify_service "code.gitea.io/gitea/services/notify" +) + +// Init initialize the repo indexer +func Init() error { + notify_service.RegisterNotifier(NewNotifier()) + + issue_indexer.InitIssueIndexer(false) + code_indexer.Init() + return stats_indexer.Init() +} diff --git a/services/indexer/notify.go b/services/indexer/notify.go new file mode 100644 index 0000000000..f1e21a2d40 --- /dev/null +++ b/services/indexer/notify.go @@ -0,0 +1,154 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package indexer + +import ( + "context" + + 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_indexer "code.gitea.io/gitea/modules/indexer/code" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + stats_indexer "code.gitea.io/gitea/modules/indexer/stats" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + notify_service "code.gitea.io/gitea/services/notify" +) + +type indexerNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &indexerNotifier{} + +// NewNotifier create a new indexerNotifier notifier +func NewNotifier() notify_service.Notifier { + return &indexerNotifier{} +} + +func (r *indexerNotifier) AdoptRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + r.MigrateRepository(ctx, doer, u, repo) +} + +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(ctx, issue.ID) +} + +func (r *indexerNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { + issue_indexer.UpdateIssueIndexer(ctx, issue.ID) +} + +func (r *indexerNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID) +} + +func (r *indexerNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { + if err := c.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + issue_indexer.UpdateIssueIndexer(ctx, c.Issue.ID) +} + +func (r *indexerNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { + if err := comment.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + issue_indexer.UpdateIssueIndexer(ctx, comment.Issue.ID) +} + +func (r *indexerNotifier) DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { + issue_indexer.DeleteRepoIssueIndexer(ctx, repo.ID) + if setting.Indexer.RepoIndexerEnabled { + code_indexer.UpdateRepoIndexer(repo) + } +} + +func (r *indexerNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + if setting.Indexer.RepoIndexerEnabled && !repo.IsEmpty { + code_indexer.UpdateRepoIndexer(repo) + } + if err := stats_indexer.UpdateRepoIndexer(repo); err != nil { + log.Error("stats_indexer.UpdateRepoIndexer(%d) failed: %v", repo.ID, err) + } +} + +func (r *indexerNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + if !opts.RefFullName.IsBranch() { + return + } + + if setting.Indexer.RepoIndexerEnabled && opts.RefFullName.BranchName() == repo.DefaultBranch { + code_indexer.UpdateRepoIndexer(repo) + } + if err := stats_indexer.UpdateRepoIndexer(repo); err != nil { + log.Error("stats_indexer.UpdateRepoIndexer(%d) failed: %v", repo.ID, err) + } +} + +func (r *indexerNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + if !opts.RefFullName.IsBranch() { + return + } + + if setting.Indexer.RepoIndexerEnabled && opts.RefFullName.BranchName() == repo.DefaultBranch { + code_indexer.UpdateRepoIndexer(repo) + } + if err := stats_indexer.UpdateRepoIndexer(repo); err != nil { + log.Error("stats_indexer.UpdateRepoIndexer(%d) failed: %v", repo.ID, err) + } +} + +func (r *indexerNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) { + if setting.Indexer.RepoIndexerEnabled && !repo.IsEmpty { + code_indexer.UpdateRepoIndexer(repo) + } + if err := stats_indexer.UpdateRepoIndexer(repo); err != nil { + log.Error("stats_indexer.UpdateRepoIndexer(%d) failed: %v", repo.ID, err) + } +} + +func (r *indexerNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { + 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(ctx, issue.ID) +} + +func (r *indexerNotifier) IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) { + 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 9b0445d29f..8740a6664a 100644 --- a/services/issue/assignee.go +++ b/services/issue/assignee.go @@ -10,10 +10,11 @@ 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" - "code.gitea.io/gitea/modules/notification" + notify_service "code.gitea.io/gitea/services/notify" ) // DeleteNotPassedAssignee deletes all assignees who aren't passed via the "assignees" array @@ -54,7 +55,7 @@ func ToggleAssigneeWithNotify(ctx context.Context, issue *issues_model.Issue, do return false, nil, err } - notification.NotifyIssueChangeAssignee(ctx, doer, issue, assignee, removed, comment) + notify_service.IssueChangeAssignee(ctx, doer, issue, assignee, removed, comment) return removed, comment, err } @@ -72,7 +73,7 @@ func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewe } if comment != nil { - notification.NotifyPullRequestReviewRequest(ctx, doer, issue, reviewer, isAdd, comment) + notify_service.PullRequestReviewRequest(ctx, doer, issue, reviewer, isAdd, comment) } return comment, err @@ -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 { @@ -259,8 +260,55 @@ func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *use continue } comment.AssigneeID = member.ID - notification.NotifyPullRequestReviewRequest(ctx, doer, issue, member, isAdd, comment) + 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 f47ef45ba0..da25da60ee 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 2c5ef0f5dc..d68623aff6 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -9,10 +9,11 @@ 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/notification" "code.gitea.io/gitea/modules/timeutil" + notify_service "code.gitea.io/gitea/services/notify" ) // CreateRefComment creates a commit reference comment to issue. @@ -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, @@ -63,13 +76,26 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m return nil, err } - notification.NotifyCreateIssueComment(ctx, doer, repo, issue, comment, mentions) + notify_service.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) return comment, nil } // 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 } @@ -95,7 +121,7 @@ func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_mode } } - notification.NotifyUpdateComment(ctx, doer, c, oldContent) + notify_service.UpdateComment(ctx, doer, c, oldContent) return nil } @@ -109,7 +135,7 @@ func DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_m return err } - notification.NotifyDeleteComment(ctx, doer, comment) + notify_service.DeleteComment(ctx, doer, comment) return nil } diff --git a/services/issue/commit.go b/services/issue/commit.go index e493a03211..0a59088d12 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/commit_test.go b/services/issue/commit_test.go index 1bc9f6f951..0518803683 100644 --- a/services/issue/commit_test.go +++ b/services/issue/commit_test.go @@ -262,7 +262,7 @@ func TestUpdateIssuesCommit_AnotherRepoNoPermission(t *testing.T) { CommitterName: "User Ten", AuthorEmail: "user10@example.com", AuthorName: "User Ten", - Message: "close user3/repo3#1", + Message: "close org3/repo3#1", }, { Sha1: "abcdef4", @@ -270,7 +270,7 @@ func TestUpdateIssuesCommit_AnotherRepoNoPermission(t *testing.T) { CommitterName: "User Ten", AuthorEmail: "user10@example.com", AuthorName: "User Ten", - Message: "close " + setting.AppURL + "user3/repo3/issues/1", + Message: "close " + setting.AppURL + "org3/repo3/issues/1", }, } diff --git a/services/issue/content.go b/services/issue/content.go index 819ac3f20f..2f9bee806a 100644 --- a/services/issue/content.go +++ b/services/issue/content.go @@ -4,21 +4,33 @@ package issue import ( - "code.gitea.io/gitea/models/db" + "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" - "code.gitea.io/gitea/modules/notification" + notify_service "code.gitea.io/gitea/services/notify" ) // ChangeContent changes issue content, as the given user. -func ChangeContent(issue *issues_model.Issue, doer *user_model.User, content string) (err error) { - oldContent := issue.Content - - if err := issues_model.ChangeIssueContent(issue, doer, content); err != nil { +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 } - notification.NotifyIssueChangeContent(db.DefaultContext, doer, issue, oldContent) + 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(ctx, issue, doer, content); err != nil { + return err + } + + notify_service.IssueChangeContent(ctx, doer, issue, oldContent) return nil } diff --git a/services/issue/issue.go b/services/issue/issue.go index 35409589ef..c7fa9f3300 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/notification" + "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) @@ -37,12 +56,12 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo return err } - notification.NotifyNewIssue(ctx, issue, mentions) + notify_service.NewIssue(ctx, issue, mentions) if len(issue.Labels) > 0 { - notification.NotifyIssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) + notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) } if issue.Milestone != nil { - notification.NotifyIssueChangeMilestone(ctx, issue.Poster, issue, 0) + notify_service.IssueChangeMilestone(ctx, issue.Poster, issue, 0) } return nil @@ -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) } } - notification.NotifyIssueChangeTitle(ctx, doer, issue, oldTitle) + notify_service.IssueChangeTitle(ctx, doer, issue, oldTitle) + ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifers) return nil } @@ -73,11 +110,11 @@ 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 } - notification.NotifyIssueChangeRef(ctx, doer, issue, oldRef) + notify_service.IssueChangeRef(ctx, doer, issue, oldRef) return nil } @@ -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) } @@ -166,7 +197,7 @@ func DeleteIssue(ctx context.Context, doer *user_model.User, gitRepo *git.Reposi } } - notification.NotifyDeleteIssue(ctx, doer, issue) + notify_service.DeleteIssue(ctx, doer, issue) return nil } @@ -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 1f6a77096e..8806cec0e7 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 ee821a49c9..6b8070d8aa 100644 --- a/services/issue/label.go +++ b/services/issue/label.go @@ -4,57 +4,59 @@ 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" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/notification" + notify_service "code.gitea.io/gitea/services/notify" ) // 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 } - notification.NotifyIssueClearLabels(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 } - notification.NotifyIssueChangeLabels(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 } - notification.NotifyIssueChangeLabels(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 } - notification.NotifyIssueChangeLabels(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 } - notification.NotifyIssueChangeLabels(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 af220601f1..90608c9e26 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 0f2427122f..5dac54183b 100644 --- a/services/issue/main_test.go +++ b/services/issue/main_test.go @@ -4,14 +4,13 @@ package issue import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/actions" ) 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 a9be8bd887..ff645744a7 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -10,7 +10,7 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/notification" + notify_service "code.gitea.io/gitea/services/notify" ) func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) error { @@ -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) } - notification.NotifyIssueChangeMilestone(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 069117d1f1..42b910166f 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 0000000000..b7b63a7024 --- /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 0000000000..deb99169e1 --- /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 60% rename from models/issues/reaction_test.go rename to services/issue/reaction_test.go index e397568ac0..7734860fc0 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, issue, nil, "heart") - addReaction(t, user1.ID, issue1ID, 0, "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) { @@ -83,23 +76,23 @@ func TestIssueReactionCount(t *testing.T) { user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) 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, user3.ID, issueID, 0, "heart") - addReaction(t, user3.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, nil, comment, "heart") - addReaction(t, user1.ID, issue1ID, comment1ID, "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) { @@ -136,20 +127,19 @@ func TestIssueCommentDeleteReaction(t *testing.T) { user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + 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, user3.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, nil, comment, "heart") + assert.NoError(t, issues_model.DeleteCommentReaction(db.DefaultContext, user1.ID, comment.IssueID, comment.ID, "heart")) - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") - assert.NoError(t, issues_model.DeleteCommentReaction(user1.ID, issue1ID, comment1ID, "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/status.go b/services/issue/status.go index 3718a5048f..9b6c683f4f 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -9,7 +9,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" + notify_service "code.gitea.io/gitea/services/notify" ) // ChangeStatus changes issue status to open or closed. @@ -30,7 +30,7 @@ func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_mod } } - notification.NotifyIssueChangeStatus(ctx, doer, commitID, issue, comment, closed) + notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, closed) return nil } diff --git a/services/issue/template.go b/services/issue/template.go index 4f1e3d93a0..dd9d015f0f 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 08d7432656..2a362b1c0d 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 58b4663345..706be0d080 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 b594e35189..dc0b539822 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 3c2b63b74e..9ec745d7ee 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 be5279aac5..fab3315be2 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 fb638ebd42..6682774a04 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 e9c1991b5b..e0d55bb120 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 b6f47ee921..ceecefa50f 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -6,13 +6,14 @@ package mailer import ( "bytes" "context" + "fmt" + "net/url" org_model "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/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" ) @@ -33,17 +34,31 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod locale := translation.NewLocale(inviter.Language) - subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) + // check if a user with this email already exists + user, err := user_model.GetUserByEmail(ctx, invite.Email) + if err != nil && !user_model.IsErrUserNotExist(err) { + return err + } else if user != nil && user.ProhibitLogin { + return fmt.Errorf("login is prohibited for the invited user") + } + + inviteRedirect := url.QueryEscape(fmt.Sprintf("/org/invite/%s", invite.Token)) + inviteURL := fmt.Sprintf("%suser/sign_up?redirect_to=%s", setting.AppURL, inviteRedirect) + + if (err == nil && user != nil) || setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration { + // user account exists or registration disabled + inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect) + } + + 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, - // helper - "locale": locale, - "Str2html": templates.Str2html, - "DotEscape": templates.DotEscape, + "InviteURL": inviteURL, } var mailBody bytes.Buffer diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 64f2f740ca..d87c57ffe7 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 ee4721d438..5e8e3dbb38 100644 --- a/services/mailer/mailer.go +++ b/services/mailer/mailer.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + notify_service "code.gitea.io/gitea/services/notify" ntlmssp "github.com/Azure/go-ntlmssp" "github.com/jaytaylor/html2text" @@ -360,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 @@ -392,6 +392,10 @@ func NewContext(ctx context.Context) { return } + if setting.Service.EnableNotifyMail { + notify_service.RegisterNotifier(NewNotifier()) + } + switch setting.MailService.Protocol { case "sendmail": Sender = &sendmailSender{} @@ -421,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 16a6a26545..f803c736ca 100644 --- a/services/mailer/main_test.go +++ b/services/mailer/main_test.go @@ -4,14 +4,13 @@ package mailer import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/actions" ) 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 new file mode 100644 index 0000000000..e48b5d399d --- /dev/null +++ b/services/mailer/notify.go @@ -0,0 +1,204 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + "fmt" + + activities_model "code.gitea.io/gitea/models/activities" + 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/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +type mailNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &mailNotifier{} + +// NewNotifier create a new mailNotifier notifier +func NewNotifier() notify_service.Notifier { + return &mailNotifier{} +} + +func (m *mailNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, +) { + var act activities_model.ActionType + if comment.Type == issues_model.CommentTypeClose { + act = activities_model.ActionCloseIssue + } else if comment.Type == issues_model.CommentTypeReopen { + act = activities_model.ActionReopenIssue + } else if comment.Type == issues_model.CommentTypeComment { + act = activities_model.ActionCommentIssue + } else if comment.Type == issues_model.CommentTypeCode { + act = activities_model.ActionCommentIssue + } else if comment.Type == issues_model.CommentTypePullRequestPush { + act = 0 + } + + if err := MailParticipantsComment(ctx, comment, act, issue, mentions); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { + if err := MailParticipants(ctx, issue, issue.Poster, activities_model.ActionCreateIssue, mentions); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { + var actionType activities_model.ActionType + if issue.IsPull { + if isClosed { + actionType = activities_model.ActionClosePullRequest + } else { + actionType = activities_model.ActionReopenPullRequest + } + } else { + if isClosed { + actionType = activities_model.ActionCloseIssue + } else { + actionType = activities_model.ActionReopenIssue + } + } + + if err := MailParticipants(ctx, issue, doer, actionType, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { + if err := issue.LoadPullRequest(ctx); err != nil { + log.Error("issue.LoadPullRequest: %v", err) + return + } + 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) + } + } +} + +func (m *mailNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { + if err := MailParticipants(ctx, pr.Issue, pr.Issue.Poster, activities_model.ActionCreatePullRequest, mentions); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { + var act activities_model.ActionType + if comment.Type == issues_model.CommentTypeClose { + act = activities_model.ActionCloseIssue + } else if comment.Type == issues_model.CommentTypeReopen { + act = activities_model.ActionReopenIssue + } else if comment.Type == issues_model.CommentTypeComment { + act = activities_model.ActionCommentPull + } + if err := MailParticipantsComment(ctx, comment, act, pr.Issue, mentions); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, comment *issues_model.Comment, mentions []*user_model.User) { + if err := MailMentionsComment(ctx, pr, comment, mentions); err != nil { + log.Error("MailMentionsComment: %v", err) + } +} + +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.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) + } + } +} + +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.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) + } + } +} + +func (m *mailNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + if err := MailParticipants(ctx, pr.Issue, doer, activities_model.ActionMergePullRequest, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("pr.LoadIssue: %v", err) + return + } + if err := MailParticipants(ctx, pr.Issue, doer, activities_model.ActionAutoMergePullRequest, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { + var err error + if err = comment.LoadIssue(ctx); err != nil { + log.Error("comment.LoadIssue: %v", err) + return + } + if err = comment.Issue.LoadRepo(ctx); err != nil { + log.Error("comment.Issue.LoadRepo: %v", err) + return + } + if err = comment.Issue.LoadPullRequest(ctx); err != nil { + log.Error("comment.Issue.LoadPullRequest: %v", err) + return + } + if err = comment.Issue.PullRequest.LoadBaseRepo(ctx); err != nil { + log.Error("comment.Issue.PullRequest.LoadBaseRepo: %v", err) + return + } + if err := comment.LoadPushCommits(ctx); err != nil { + log.Error("comment.LoadPushCommits: %v", err) + } + m.CreateIssueComment(ctx, doer, comment.Issue.Repo, comment.Issue, comment, nil) +} + +func (m *mailNotifier) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + if err := comment.Review.LoadReviewer(ctx); err != nil { + log.Error("Error in PullReviewDismiss while loading reviewer for issue[%d], review[%d] and reviewer[%d]: %v", review.Issue.ID, comment.Review.ID, comment.Review.ReviewerID, err) + } + if err := MailParticipantsComment(ctx, comment, activities_model.ActionPullReviewDismissed, review.Issue, nil); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { + if err := rel.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if rel.IsDraft || rel.IsPrerelease { + return + } + + MailNewRelease(ctx, rel) +} + +func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { + if err := SendRepoTransferNotifyMail(ctx, doer, newOwner, repo); err != nil { + log.Error("SendRepoTransferNotifyMail: %v", err) + } +} diff --git a/services/mailer/token/token.go b/services/mailer/token/token.go index aa7b567188..8a5a762d6b 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 ce892435a1..5553ebc058 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 3551f85c46..68487fb8db 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 0000000000..ef95046128 --- /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 0000000000..01db792925 --- /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 d83e10903f..170edae0e0 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/test" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -42,7 +42,7 @@ func TestProcessorHelper(t *testing.T) { assert.NoError(t, err) base, baseCleanUp := gitea_context.NewBaseContext(httptest.NewRecorder(), req) defer baseCleanUp() - giteaCtx := gitea_context.NewWebContext(base, &test.MockRender{}, nil) + giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil) assert.True(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPublic)) assert.False(t, ProcessorHelper().IsUsernameMentionable(giteaCtx, userPrivate)) diff --git a/services/migrations/common.go b/services/migrations/common.go index 4f9837472d..d88518899d 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 603954810c..07812002af 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 c8e6c8fe13..5e0e0742c9 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 b9ba93325b..d402a238f2 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 ee7fc57851..87691bf729 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" @@ -31,6 +33,7 @@ import ( "code.gitea.io/gitea/modules/uri" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" "github.com/google/uuid" ) @@ -99,7 +102,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate var r *repo_model.Repository if opts.MigrateToRepoID <= 0 { - r, err = repo_module.CreateRepository(g.doer, owner, repo_module.CreateRepoOptions{ + r, err = repo_service.CreateRepositoryDirectly(g.ctx, g.doer, owner, repo_service.CreateRepoOptions{ Name: g.repoName, Description: repo.Description, OriginalURL: repo.OriginalURL, @@ -117,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, @@ -137,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 @@ -161,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 @@ -204,7 +217,7 @@ func (g *GiteaLocalUploader) CreateMilestones(milestones ...*base.Milestone) err mss = append(mss, &ms) } - err := models.InsertMilestones(mss...) + err := issues_model.InsertMilestones(g.ctx, mss...) if err != nil { return err } @@ -235,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 } @@ -349,12 +362,12 @@ func (g *GiteaLocalUploader) CreateReleases(releases ...*base.Release) error { rels = append(rels, &rel) } - return models.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 @@ -396,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", @@ -429,7 +442,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error { } if len(iss) > 0 { - if err := models.InsertIssues(iss...); err != nil { + if err := issues_model.InsertIssues(g.ctx, iss...); err != nil { return err } @@ -470,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) @@ -479,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: } @@ -509,13 +536,12 @@ func (g *GiteaLocalUploader) CreateComments(comments ...*base.Comment) error { if len(cms) == 0 { return nil } - return models.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 { @@ -528,12 +554,12 @@ func (g *GiteaLocalUploader) CreatePullRequests(prs ...*base.PullRequest) error gprs = append(gprs, gpr) } - if err := models.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 } @@ -840,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 } @@ -862,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) } @@ -892,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 } @@ -917,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. @@ -937,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 } @@ -989,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 878b6d6b84..c9b9248098 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) @@ -113,7 +116,7 @@ func TestGiteaUploadRepo(t *testing.T) { assert.NoError(t, issues[0].LoadDiscussComments(db.DefaultContext)) assert.Empty(t, issues[0].Comments) - pulls, _, err := issues_model.PullRequests(repo.ID, &issues_model.PullRequestsOptions{ + pulls, _, err := issues_model.PullRequests(db.DefaultContext, repo.ID, &issues_model.PullRequestsOptions{ 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 f27c1a34da..be573b33b3 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 76180a5159..bbc44e958a 100644 --- a/services/migrations/gitlab.go +++ b/services/migrations/gitlab.go @@ -11,9 +11,12 @@ 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" "code.gitea.io/gitea/modules/structs" @@ -54,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 @@ -309,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, @@ -316,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 } @@ -448,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 @@ -487,30 +508,8 @@ 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 { @@ -518,20 +517,106 @@ func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Co } 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) @@ -540,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 { @@ -565,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 @@ -592,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, @@ -607,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), @@ -673,16 +769,15 @@ func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Revie func (g *GitlabDownloader) awardsToReactions(awards []*gitlab.AwardEmoji) []*base.Reaction { result := make([]*base.Reaction, 0, len(awards)) - uniqCheck := make(map[string]struct{}) + uniqCheck := make(container.Set[string]) for _, award := range awards { uid := fmt.Sprintf("%s%d", award.Name, award.User.ID) - if _, ok := uniqCheck[uid]; !ok { + if uniqCheck.Add(uid) { result = append(result, &base.Reaction{ UserID: int64(award.User.ID), UserName: award.User.Username, Content: award.Name, }) - uniqCheck[uid] = struct{}{} } } return result diff --git a/services/migrations/gitlab_test.go b/services/migrations/gitlab_test.go index 731486eff2..0b9eeaed54 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 42c433fb00..d0ec6a3f8d 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 0ebb3411fd..5bb3056161 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 48b61885e8..4a49206f82 100644 --- a/services/migrations/update.go +++ b/services/migrations/update.go @@ -6,11 +6,11 @@ package migrations import ( "context" - "code.gitea.io/gitea/models" "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/structs" + "code.gitea.io/gitea/services/externalaccount" ) // UpdateMigrationPosterID updates all migrated repositories' issues and comments posterID @@ -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 := models.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 0fc871b214..72e545581a 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 51c7de58b6..2a38d4ba55 100644 --- a/services/mirror/mirror_pull.go +++ b/services/mirror/mirror_pull.go @@ -9,20 +9,20 @@ 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/notification" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/proxy" 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" + notify_service "code.gitea.io/gitea/services/notify" ) // gitShortEmptySha Git short empty SHA @@ -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,18 +479,19 @@ 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 } - notification.NotifySyncPushCommits(ctx, m.Repo.MustOwner(ctx), m.Repo, &repo_module.PushUpdateOptions{ + 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()) - notification.NotifySyncCreateRef(ctx, m.Repo.MustOwner(ctx), m.Repo, result.refName, commitID) + notify_service.SyncCreateRef(ctx, m.Repo.MustOwner(ctx), m.Repo, result.refName, commitID) continue } // Delete reference if result.newCommitID == gitShortEmptySha { - notification.NotifySyncDeleteRef(ctx, m.Repo.MustOwner(ctx), m.Repo, result.refName) + notify_service.SyncDeleteRef(ctx, m.Repo.MustOwner(ctx), m.Repo, result.refName) continue } @@ -525,7 +526,7 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool { theCommits.CompareURL = m.Repo.ComposeCompareURL(oldCommitID, newCommitID) - notification.NotifySyncPushCommits(ctx, m.Repo.MustOwner(ctx), m.Repo, &repo_module.PushUpdateOptions{ + notify_service.SyncPushCommits(ctx, m.Repo.MustOwner(ctx), m.Repo, &repo_module.PushUpdateOptions{ RefFullName: result.refName, OldCommitID: oldCommitID, NewCommitID: newCommitID, @@ -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 594d31df89..21ba0afeff 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/mirror/notifier.go b/services/mirror/notifier.go index e0e1b443e0..93d904470d 100644 --- a/services/mirror/notifier.go +++ b/services/mirror/notifier.go @@ -8,25 +8,24 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/notification" - "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/repository" + notify_service "code.gitea.io/gitea/services/notify" ) func init() { - notification.RegisterNotifier(&mirrorNotifier{}) + notify_service.RegisterNotifier(&mirrorNotifier{}) } type mirrorNotifier struct { - base.NullNotifier + notify_service.NullNotifier } -var _ base.Notifier = &mirrorNotifier{} +var _ notify_service.Notifier = &mirrorNotifier{} -func (m *mirrorNotifier) NotifyPushCommits(ctx context.Context, _ *user_model.User, repo *repo_model.Repository, _ *repository.PushUpdateOptions, _ *repository.PushCommits) { +func (m *mirrorNotifier) PushCommits(ctx context.Context, _ *user_model.User, repo *repo_model.Repository, _ *repository.PushUpdateOptions, _ *repository.PushCommits) { syncPushMirrorWithSyncOnCommit(ctx, repo.ID) } -func (m *mirrorNotifier) NotifySyncPushCommits(ctx context.Context, _ *user_model.User, repo *repo_model.Repository, _ *repository.PushUpdateOptions, _ *repository.PushCommits) { +func (m *mirrorNotifier) SyncPushCommits(ctx context.Context, _ *user_model.User, repo *repo_model.Repository, _ *repository.PushUpdateOptions, _ *repository.PushCommits) { syncPushMirrorWithSyncOnCommit(ctx, repo.ID) } diff --git a/services/notify/notifier.go b/services/notify/notifier.go new file mode 100644 index 0000000000..ed053a812a --- /dev/null +++ b/services/notify/notifier.go @@ -0,0 +1,77 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + packages_model "code.gitea.io/gitea/models/packages" + 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/repository" +) + +// Notifier defines an interface to notify receiver +type Notifier interface { + Run() + + AdoptRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) + CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) + MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) + DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) + ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) + RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) + TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) + RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) + + NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) + IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) + DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) + IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) + IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) + PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) + IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) + IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) + IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) + IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) + IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, + addedLabels, removedLabels []*issues_model.Label) + + NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) + MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) + AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) + PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) + PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) + PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, comment *issues_model.Comment, mentions []*user_model.User) + PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) + PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) + PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) + + CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User) + UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) + DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) + + NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) + EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) + DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) + + NewRelease(ctx context.Context, rel *repo_model.Release) + UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) + DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) + + PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) + CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) + DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) + SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) + SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) + SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) + + PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) + PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) + + ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) +} diff --git a/services/notify/notify.go b/services/notify/notify.go new file mode 100644 index 0000000000..16fbb6325d --- /dev/null +++ b/services/notify/notify.go @@ -0,0 +1,369 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + packages_model "code.gitea.io/gitea/models/packages" + 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" + "code.gitea.io/gitea/modules/repository" +) + +var notifiers []Notifier + +// RegisterNotifier providers method to receive notify messages +func RegisterNotifier(notifier Notifier) { + go notifier.Run() + notifiers = append(notifiers, notifier) +} + +// NewWikiPage notifies creating new wiki pages to notifiers +func NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { + for _, notifier := range notifiers { + notifier.NewWikiPage(ctx, doer, repo, page, comment) + } +} + +// EditWikiPage notifies editing or renaming wiki pages to notifiers +func EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { + for _, notifier := range notifiers { + notifier.EditWikiPage(ctx, doer, repo, page, comment) + } +} + +// DeleteWikiPage notifies deleting wiki pages to notifiers +func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { + for _, notifier := range notifiers { + notifier.DeleteWikiPage(ctx, doer, repo, page) + } +} + +// CreateIssueComment notifies issue comment related message to notifiers +func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, +) { + for _, notifier := range notifiers { + notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) + } +} + +// NewIssue notifies new issue to notifiers +func NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { + for _, notifier := range notifiers { + notifier.NewIssue(ctx, issue, mentions) + } +} + +// IssueChangeStatus notifies close or reopen issue to notifiers +func IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) { + for _, notifier := range notifiers { + notifier.IssueChangeStatus(ctx, doer, commitID, issue, actionComment, closeOrReopen) + } +} + +// DeleteIssue notify when some issue deleted +func DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { + for _, notifier := range notifiers { + notifier.DeleteIssue(ctx, doer, issue) + } +} + +// MergePullRequest notifies merge pull request to notifiers +func MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + for _, notifier := range notifiers { + notifier.MergePullRequest(ctx, doer, pr) + } +} + +// AutoMergePullRequest notifies merge pull request to notifiers +func AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + for _, notifier := range notifiers { + notifier.AutoMergePullRequest(ctx, doer, pr) + } +} + +// 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) + return + } + if err := pr.Issue.LoadPoster(ctx); err != nil { + return + } + for _, notifier := range notifiers { + notifier.NewPullRequest(ctx, pr, mentions) + } +} + +// PullRequestSynchronized notifies Synchronized pull request +func PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + for _, notifier := range notifiers { + notifier.PullRequestSynchronized(ctx, doer, pr) + } +} + +// 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) + return + } + for _, notifier := range notifiers { + notifier.PullRequestReview(ctx, pr, review, comment, mentions) + } +} + +// PullRequestCodeComment notifies new pull request code comment +func PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, comment *issues_model.Comment, mentions []*user_model.User) { + if err := comment.LoadPoster(ctx); err != nil { + log.Error("LoadPoster: %v", err) + return + } + for _, notifier := range notifiers { + notifier.PullRequestCodeComment(ctx, pr, comment, mentions) + } +} + +// PullRequestChangeTargetBranch notifies when a pull request's target branch was changed +func PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { + for _, notifier := range notifiers { + notifier.PullRequestChangeTargetBranch(ctx, doer, pr, oldBranch) + } +} + +// PullRequestPushCommits notifies when push commits to pull request's head branch +func PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { + for _, notifier := range notifiers { + notifier.PullRequestPushCommits(ctx, doer, pr, comment) + } +} + +// PullReviewDismiss notifies when a review was dismissed by repo admin +func PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + for _, notifier := range notifiers { + notifier.PullReviewDismiss(ctx, doer, review, comment) + } +} + +// UpdateComment notifies update comment to notifiers +func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { + for _, notifier := range notifiers { + notifier.UpdateComment(ctx, doer, c, oldContent) + } +} + +// DeleteComment notifies delete comment to notifiers +func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) { + for _, notifier := range notifiers { + notifier.DeleteComment(ctx, doer, c) + } +} + +// NewRelease notifies new release to notifiers +func NewRelease(ctx context.Context, rel *repo_model.Release) { + if err := rel.LoadAttributes(ctx); err != nil { + log.Error("LoadPublisher: %v", err) + return + } + for _, notifier := range notifiers { + notifier.NewRelease(ctx, rel) + } +} + +// UpdateRelease notifies update release to notifiers +func UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + for _, notifier := range notifiers { + notifier.UpdateRelease(ctx, doer, rel) + } +} + +// DeleteRelease notifies delete release to notifiers +func DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + for _, notifier := range notifiers { + notifier.DeleteRelease(ctx, doer, rel) + } +} + +// IssueChangeMilestone notifies change milestone to notifiers +func IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { + for _, notifier := range notifiers { + notifier.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID) + } +} + +// IssueChangeContent notifies change content to notifiers +func IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { + for _, notifier := range notifiers { + notifier.IssueChangeContent(ctx, doer, issue, oldContent) + } +} + +// IssueChangeAssignee notifies change content to notifiers +func IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { + for _, notifier := range notifiers { + notifier.IssueChangeAssignee(ctx, doer, issue, assignee, removed, comment) + } +} + +// PullRequestReviewRequest notifies Request Review change +func PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { + for _, notifier := range notifiers { + notifier.PullRequestReviewRequest(ctx, doer, issue, reviewer, isRequest, comment) + } +} + +// IssueClearLabels notifies clear labels to notifiers +func IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { + for _, notifier := range notifiers { + notifier.IssueClearLabels(ctx, doer, issue) + } +} + +// IssueChangeTitle notifies change title to notifiers +func IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { + for _, notifier := range notifiers { + notifier.IssueChangeTitle(ctx, doer, issue, oldTitle) + } +} + +// IssueChangeRef notifies change reference to notifiers +func IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string) { + for _, notifier := range notifiers { + notifier.IssueChangeRef(ctx, doer, issue, oldRef) + } +} + +// IssueChangeLabels notifies change labels to notifiers +func IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, + addedLabels, removedLabels []*issues_model.Label, +) { + for _, notifier := range notifiers { + notifier.IssueChangeLabels(ctx, doer, issue, addedLabels, removedLabels) + } +} + +// CreateRepository notifies create repository to notifiers +func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.CreateRepository(ctx, doer, u, repo) + } +} + +// AdoptRepository notifies the adoption of a repository to notifiers +func AdoptRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.AdoptRepository(ctx, doer, u, repo) + } +} + +// MigrateRepository notifies create repository to notifiers +func MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.MigrateRepository(ctx, doer, u, repo) + } +} + +// TransferRepository notifies create repository to notifiers +func TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newOwnerName string) { + for _, notifier := range notifiers { + notifier.TransferRepository(ctx, doer, repo, newOwnerName) + } +} + +// DeleteRepository notifies delete repository to notifiers +func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.DeleteRepository(ctx, doer, repo) + } +} + +// ForkRepository notifies fork repository to notifiers +func ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.ForkRepository(ctx, doer, oldRepo, repo) + } +} + +// RenameRepository notifies repository renamed +func RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldName string) { + for _, notifier := range notifiers { + notifier.RenameRepository(ctx, doer, repo, oldName) + } +} + +// PushCommits notifies commits pushed to notifiers +func PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + for _, notifier := range notifiers { + notifier.PushCommits(ctx, pusher, repo, opts, commits) + } +} + +// CreateRef notifies branch or tag creation to notifiers +func CreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + for _, notifier := range notifiers { + notifier.CreateRef(ctx, pusher, repo, refFullName, refID) + } +} + +// DeleteRef notifies branch or tag deletion to notifiers +func DeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + for _, notifier := range notifiers { + notifier.DeleteRef(ctx, pusher, repo, refFullName) + } +} + +// SyncPushCommits notifies commits pushed to notifiers +func SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + for _, notifier := range notifiers { + notifier.SyncPushCommits(ctx, pusher, repo, opts, commits) + } +} + +// SyncCreateRef notifies branch or tag creation to notifiers +func SyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + for _, notifier := range notifiers { + notifier.SyncCreateRef(ctx, pusher, repo, refFullName, refID) + } +} + +// SyncDeleteRef notifies branch or tag deletion to notifiers +func SyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + for _, notifier := range notifiers { + notifier.SyncDeleteRef(ctx, pusher, repo, refFullName) + } +} + +// RepoPendingTransfer notifies creation of pending transfer to notifiers +func RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.RepoPendingTransfer(ctx, doer, newOwner, repo) + } +} + +// PackageCreate notifies creation of a package to notifiers +func PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + for _, notifier := range notifiers { + notifier.PackageCreate(ctx, doer, pd) + } +} + +// PackageDelete notifies deletion of a package to notifiers +func PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + for _, notifier := range notifiers { + notifier.PackageDelete(ctx, doer, pd) + } +} + +// ChangeDefaultBranch notifies change default branch to notifiers +func ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) { + for _, notifier := range notifiers { + notifier.ChangeDefaultBranch(ctx, repo) + } +} diff --git a/services/notify/null.go b/services/notify/null.go new file mode 100644 index 0000000000..dddd421bef --- /dev/null +++ b/services/notify/null.go @@ -0,0 +1,210 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + packages_model "code.gitea.io/gitea/models/packages" + 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/repository" +) + +// NullNotifier implements a blank notifier +type NullNotifier struct{} + +var _ Notifier = &NullNotifier{} + +// Run places a place holder function +func (*NullNotifier) Run() { +} + +// CreateIssueComment places a place holder function +func (*NullNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User) { +} + +// NewIssue places a place holder function +func (*NullNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { +} + +// IssueChangeStatus places a place holder function +func (*NullNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { +} + +// DeleteIssue notify when some issue deleted +func (*NullNotifier) DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { +} + +// NewPullRequest places a place holder function +func (*NullNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { +} + +// PullRequestReview places a place holder function +func (*NullNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { +} + +// PullRequestCodeComment places a place holder function +func (*NullNotifier) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, comment *issues_model.Comment, mentions []*user_model.User) { +} + +// MergePullRequest places a place holder function +func (*NullNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +} + +// AutoMergePullRequest places a place holder function +func (*NullNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +} + +// PullRequestSynchronized places a place holder function +func (*NullNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +} + +// PullRequestChangeTargetBranch places a place holder function +func (*NullNotifier) PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { +} + +// PullRequestPushCommits notifies when push commits to pull request's head branch +func (*NullNotifier) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { +} + +// PullReviewDismiss notifies when a review was dismissed by repo admin +func (*NullNotifier) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { +} + +// UpdateComment places a place holder function +func (*NullNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { +} + +// DeleteComment places a place holder function +func (*NullNotifier) DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) { +} + +// NewWikiPage places a place holder function +func (*NullNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { +} + +// EditWikiPage places a place holder function +func (*NullNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { +} + +// DeleteWikiPage places a place holder function +func (*NullNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { +} + +// NewRelease places a place holder function +func (*NullNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { +} + +// UpdateRelease places a place holder function +func (*NullNotifier) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { +} + +// DeleteRelease places a place holder function +func (*NullNotifier) DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { +} + +// IssueChangeMilestone places a place holder function +func (*NullNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { +} + +// IssueChangeContent places a place holder function +func (*NullNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { +} + +// IssueChangeAssignee places a place holder function +func (*NullNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { +} + +// PullRequestReviewRequest places a place holder function +func (*NullNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { +} + +// IssueClearLabels places a place holder function +func (*NullNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { +} + +// IssueChangeTitle places a place holder function +func (*NullNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { +} + +// IssueChangeRef places a place holder function +func (*NullNotifier) IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { +} + +// IssueChangeLabels places a place holder function +func (*NullNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, + addedLabels, removedLabels []*issues_model.Label) { +} + +// CreateRepository places a place holder function +func (*NullNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +} + +// AdoptRepository places a place holder function +func (*NullNotifier) AdoptRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +} + +// DeleteRepository places a place holder function +func (*NullNotifier) DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { +} + +// ForkRepository places a place holder function +func (*NullNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { +} + +// MigrateRepository places a place holder function +func (*NullNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +} + +// PushCommits notifies commits pushed to notifiers +func (*NullNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +} + +// CreateRef notifies branch or tag creation to notifiers +func (*NullNotifier) CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { +} + +// DeleteRef notifies branch or tag deletion to notifiers +func (*NullNotifier) DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { +} + +// RenameRepository places a place holder function +func (*NullNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) { +} + +// TransferRepository places a place holder function +func (*NullNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) { +} + +// SyncPushCommits places a place holder function +func (*NullNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +} + +// SyncCreateRef places a place holder function +func (*NullNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { +} + +// SyncDeleteRef places a place holder function +func (*NullNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { +} + +// RepoPendingTransfer places a place holder function +func (*NullNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { +} + +// PackageCreate places a place holder function +func (*NullNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { +} + +// PackageDelete places a place holder function +func (*NullNotifier) PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { +} + +// ChangeDefaultBranch places a place holder function +func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) { +} diff --git a/services/org/org.go b/services/org/org.go index a62e5b6fc8..dca7794b47 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 cc22595c6f..e7d2a18ea9 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/org/repo.go b/services/org/repo.go index 179249c7a8..78a829ef25 100644 --- a/services/org/repo.go +++ b/services/org/repo.go @@ -14,14 +14,14 @@ import ( ) // TeamAddRepository adds new repository to team of organization. -func TeamAddRepository(t *organization.Team, repo *repo_model.Repository) (err error) { +func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { if repo.OwnerID != t.OrgID { return errors.New("repository does not belong to organization") - } else if models.HasRepository(t, repo.ID) { + } else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) { return nil } - return db.WithTx(db.DefaultContext, func(ctx context.Context) error { + return db.WithTx(ctx, func(ctx context.Context) error { return models.AddRepository(ctx, t, repo) }) } diff --git a/services/org/repo_test.go b/services/org/repo_test.go index 40b0d17077..68c64a01ab 100644 --- a/services/org/repo_test.go +++ b/services/org/repo_test.go @@ -6,6 +6,7 @@ package org import ( "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -19,7 +20,7 @@ func TestTeam_AddRepository(t *testing.T) { testSuccess := func(teamID, repoID int64) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) - assert.NoError(t, TeamAddRepository(team, repo)) + assert.NoError(t, TeamAddRepository(db.DefaultContext, team, repo)) unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID}) unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID}) } @@ -28,6 +29,6 @@ func TestTeam_AddRepository(t *testing.T) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - assert.Error(t, TeamAddRepository(team, repo)) + assert.Error(t, TeamAddRepository(db.DefaultContext, team, repo)) unittest.CheckConsistencyFor(t, &organization.Team{ID: 1}, &repo_model.Repository{ID: 1}) } diff --git a/services/packages/alpine/repository.go b/services/packages/alpine/repository.go index 5264bd6c4a..664ab34559 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,22 +31,25 @@ 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 -func GetOrCreateKeyPair(ownerID int64) (string, string, error) { - priv, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPrivate) +func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, error) { + priv, err := user_model.GetSetting(ctx, ownerID, alpine_module.SettingKeyPrivate) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } - pub, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPublic) + pub, err := user_model.GetSetting(ctx, ownerID, alpine_module.SettingKeyPublic) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } @@ -56,11 +60,11 @@ func GetOrCreateKeyPair(ownerID int64) (string, string, error) { return "", "", err } - if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPrivate, priv); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, alpine_module.SettingKeyPrivate, priv); err != nil { return "", "", err } - if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPublic, pub); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, alpine_module.SettingKeyPublic, pub); err != nil { return "", "", err } } @@ -70,7 +74,7 @@ func GetOrCreateKeyPair(ownerID int64) (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 } @@ -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,17 +265,25 @@ 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 } - priv, _, err := GetOrCreateKeyPair(ownerID) + priv, _, err := GetOrCreateKeyPair(ctx, ownerID) if 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 2f78b26f50..8263c28bed 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 867cd796d3..e8a8313625 100644 --- a/services/packages/cargo/index.go +++ b/services/packages/cargo/index.go @@ -19,10 +19,10 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" cargo_module "code.gitea.io/gitea/modules/packages/cargo" - 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" + repo_service "code.gitea.io/gitea/services/repository" files_service "code.gitea.io/gitea/services/repository/files" ) @@ -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) @@ -206,7 +212,7 @@ func getOrCreateIndexRepository(ctx context.Context, doer, owner *user_model.Use repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner.Name, IndexRepositoryName) if err != nil { if errors.Is(err, util.ErrNotExist) { - repo, err = repo_module.CreateRepository(doer, owner, repo_module.CreateRepoOptions{ + repo, err = repo_service.CreateRepositoryDirectly(ctx, doer, owner, repo_service.CreateRepoOptions{ Name: IndexRepositoryName, }) if err != nil { @@ -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 77bcfb1942..5d5120c6a0 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 1a9ef26391..3f5f43bbc0 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 be82fbed6e..611faa6ade 100644 --- a/services/packages/debian/repository.go +++ b/services/packages/debian/repository.go @@ -32,18 +32,18 @@ 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 -func GetOrCreateKeyPair(ownerID int64) (string, string, error) { - priv, err := user_model.GetSetting(ownerID, debian_module.SettingKeyPrivate) +func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, error) { + priv, err := user_model.GetSetting(ctx, ownerID, debian_module.SettingKeyPrivate) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } - pub, err := user_model.GetSetting(ownerID, debian_module.SettingKeyPublic) + pub, err := user_model.GetSetting(ctx, ownerID, debian_module.SettingKeyPublic) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } @@ -54,11 +54,11 @@ func GetOrCreateKeyPair(ownerID int64) (string, string, error) { return "", "", err } - if err := user_model.SetUserSetting(ownerID, debian_module.SettingKeyPrivate, priv); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, debian_module.SettingKeyPrivate, priv); err != nil { return "", "", err } - if err := user_model.SetUserSetting(ownerID, debian_module.SettingKeyPublic, pub); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, debian_module.SettingKeyPublic, pub); err != nil { return "", "", err } } @@ -67,7 +67,7 @@ func GetOrCreateKeyPair(ownerID int64) (string, string, error) { } 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 } } @@ -306,7 +308,7 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages sort.Strings(architectures) - priv, _, err := GetOrCreateKeyPair(ownerID) + priv, _, err := GetOrCreateKeyPair(ctx, ownerID) if 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 bdc56efeef..64b1ddd869 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -18,11 +18,11 @@ 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/notification" + "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" ) var ( @@ -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 } - notification.NotifyPackageCreate(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 } - notification.NotifyPackageDelete(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 { - notification.NotifyPackageDelete(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 cfd70ec23e..c52c8a5dd9 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,18 +33,18 @@ 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 -func GetOrCreateKeyPair(ownerID int64) (string, string, error) { - priv, err := user_model.GetSetting(ownerID, rpm_module.SettingKeyPrivate) +func GetOrCreateKeyPair(ctx context.Context, ownerID int64) (string, string, error) { + priv, err := user_model.GetSetting(ctx, ownerID, rpm_module.SettingKeyPrivate) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } - pub, err := user_model.GetSetting(ownerID, rpm_module.SettingKeyPublic) + pub, err := user_model.GetSetting(ctx, ownerID, rpm_module.SettingKeyPublic) if err != nil && !errors.Is(err, util.ErrNotExist) { return "", "", err } @@ -55,11 +55,11 @@ func GetOrCreateKeyPair(ownerID int64) (string, string, error) { return "", "", err } - if err := user_model.SetUserSetting(ownerID, rpm_module.SettingKeyPrivate, priv); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, rpm_module.SettingKeyPrivate, priv); err != nil { return "", "", err } - if err := user_model.SetUserSetting(ownerID, rpm_module.SettingKeyPublic, pub); err != nil { + if err := user_model.SetUserSetting(ctx, ownerID, rpm_module.SettingKeyPublic, pub); err != nil { return "", "", err } } @@ -68,7 +68,7 @@ func GetOrCreateKeyPair(ownerID int64) (string, string, error) { } 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,20 +229,21 @@ 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 } return buildRepomd( + ctx, pv, ownerID, []*repoData{ @@ -219,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(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"` @@ -241,7 +274,7 @@ func buildRepomd(pv *packages_model.PackageVersion, ownerID int64, data []*repoD return err } - priv, _, err := GetOrCreateKeyPair(ownerID) + priv, _, err := GetOrCreateKeyPair(ctx, ownerID) if err != nil { return err } @@ -257,11 +290,14 @@ func buildRepomd(pv *packages_model.PackageVersion, ownerID int64, data []*repoD } 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 @@ -271,10 +307,12 @@ func buildRepomd(pv *packages_model.PackageVersion, ownerID int64, data []*repoD {"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, @@ -291,7 +329,7 @@ func buildRepomd(pv *packages_model.PackageVersion, ownerID int64, data []*repoD } // 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"` @@ -371,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, @@ -400,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, @@ -425,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"` @@ -473,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"` @@ -520,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 @@ -544,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() @@ -564,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 ec898201bb..f4dd332b14 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -20,13 +20,14 @@ 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/notification" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/timeutil" asymkey_service "code.gitea.io/gitea/services/asymkey" + notify_service "code.gitea.io/gitea/services/notify" ) // prPatchCheckerQueue represents a queue to handle update pull request tests @@ -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) @@ -295,7 +298,7 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool { return false } - notification.NotifyMergePullRequest(ctx, merger, pr) + notify_service.MergePullRequest(ctx, merger, pr) log.Info("manuallyMerged[%-v]: Marked as manually merged into %s/%s by commit id: %s", pr, pr.BaseRepo.Name, pr.BaseBranch, commit.ID.String()) return true @@ -303,7 +306,7 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool { // InitializePullRequests checks and tests untested patches of pull requests. func InitializePullRequests(ctx context.Context) { - prs, err := issues_model.GetPullRequestIDsByCheckStatus(issues_model.PullRequestStatusChecking) + prs, err := issues_model.GetPullRequestIDsByCheckStatus(ctx, issues_model.PullRequestStatusChecking) if err != nil { log.Error("Find Checking PRs: %v", err) return @@ -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 4a99859f5a..dcf5f7b93a 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 14fba52f1e..d538b118d5 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 39d60380ff..aa1ad7cd66 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 0000000000..592acdd55c --- /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 3943372468..ed03583d4f 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 2014b19275..efbb63a36e 100644 --- a/services/pull/main_test.go +++ b/services/pull/main_test.go @@ -5,14 +5,13 @@ package pull import ( - "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models/actions" ) 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 7051fd9eda..e37540a96f 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -25,12 +25,12 @@ import ( "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/references" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" ) // getMergeMessage composes the message used when merging a pull request. @@ -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 := "#" @@ -206,9 +209,9 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U } if wasAutoMerged { - notification.NotifyAutoMergePullRequest(ctx, doer, pr) + notify_service.AutoMergePullRequest(ctx, doer, pr) } else { - notification.NotifyMergePullRequest(ctx, doer, pr) + notify_service.MergePullRequest(ctx, doer, pr) } // Reset cached commit count @@ -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") } @@ -521,7 +536,7 @@ func MergedManually(pr *issues_model.PullRequest, doer *user_model.User, baseGit return err } - notification.NotifyMergePullRequest(baseGitRepo.Ctx, doer, pr) + notify_service.MergePullRequest(baseGitRepo.Ctx, doer, pr) log.Info("manuallyMerged[%d]: Marked as manually merged into %s/%s by commit id: %s", pr.ID, pr.BaseRepo.Name, pr.BaseBranch, commitID) return nil } diff --git a/services/pull/merge_ff_only.go b/services/pull/merge_ff_only.go new file mode 100644 index 0000000000..f57c732104 --- /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 0f7664297a..bf56c071db 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 a88f805ef0..ecf376220e 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 f52a2301d9..197d8102dd 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 688cbcc027..12b79a0625 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 d4352abafe..c091b8608a 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -4,6 +4,7 @@ package pull import ( + "bytes" "context" "fmt" "io" @@ -20,17 +21,18 @@ 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" - "code.gitea.io/gitea/modules/notification" repo_module "code.gitea.io/gitea/modules/repository" "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" ) // TODO: use clustered lock (unique queue? or *abuse* cache) @@ -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,26 +152,25 @@ 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 } - - notification.NotifyNewPullRequest(ctx, pr, mentions) + notify_service.NewPullRequest(ctx, pr, mentions) if len(issue.Labels) > 0 { - notification.NotifyIssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) + notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) } if issue.Milestone != nil { - notification.NotifyIssueChangeMilestone(ctx, issue.Poster, issue, 0) + 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 - } - notification.NotifyIssueChangeAssignee(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... @@ -315,7 +326,7 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, AddToTaskQueue(ctx, pr) comment, err := CreatePushPullComment(ctx, doer, pr, oldCommitID, newCommitID) if err == nil && comment != nil { - notification.NotifyPullRequestPushCommits(ctx, doer, pr, comment) + notify_service.PullRequestPushCommits(ctx, doer, pr, comment) } } @@ -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) @@ -365,7 +377,7 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, } } - notification.NotifyPullRequestSynchronized(ctx, doer, pr) + notify_service.PullRequestSynchronized(ctx, doer, 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 } diff --git a/services/pull/pull_test.go b/services/pull/pull_test.go index d63227a7d5..787910bf76 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 6e088382f9..5bf1991d13 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -16,14 +16,33 @@ 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/notification" + "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" ) 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 @@ -113,7 +131,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. return nil, err } - notification.NotifyCreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions) + notify_service.CreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions) return comment, nil } @@ -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 } @@ -298,7 +318,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos return nil, nil, err } - notification.NotifyPullRequestReview(ctx, pr, review, comm, mentions) + notify_service.PullRequestReview(ctx, pr, review, comm, mentions) for _, lines := range review.CodeComments { for _, comments := range lines { @@ -307,7 +327,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos if err != nil { return nil, nil, err } - notification.NotifyPullRequestCodeComment(ctx, pr, codeComment, mentions) + notify_service.PullRequestCodeComment(ctx, pr, codeComment, mentions) } } } @@ -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 @@ -355,7 +373,7 @@ func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *is comment.Poster = doer comment.Issue = review.Issue - notification.NotifyPullReviewDismiss(ctx, doer, review, comment) + notify_service.PullReviewDismiss(ctx, doer, review, comment) } return nil }) @@ -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 @@ -426,7 +459,7 @@ func DismissReview(ctx context.Context, reviewID, repoID int64, message string, comment.Poster = doer comment.Issue = review.Issue - notification.NotifyPullReviewDismiss(ctx, doer, review, comment) + notify_service.PullReviewDismiss(ctx, doer, review, comment) return comment, nil } diff --git a/services/pull/review_test.go b/services/pull/review_test.go new file mode 100644 index 0000000000..3bce1e523d --- /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 db32940e38..36bdbde55c 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 1ccbd9c811..ba5fd1dd98 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -16,15 +16,27 @@ 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/notification" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" ) 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,19 +88,20 @@ 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) - notification.NotifyPushCommits( + notify_service.PushCommits( ctx, rel.Publisher, rel.Repo, &repository.PushUpdateOptions{ RefFullName: refFullName, - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: commit.ID.String(), }, commits) - notification.NotifyCreateRef(ctx, rel.Publisher, rel.Repo, refFullName, commit.ID.String()) + notify_service.CreateRef(ctx, rel.Publisher, rel.Repo, refFullName, commit.ID.String()) rel.CreatedUnix = timeutil.TimeStampNow() } commit, err := gitRepo.GetTagCommit(rel.TagName) @@ -139,7 +152,7 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU } if !rel.IsDraft { - notification.NotifyNewRelease(gitRepo.Ctx, rel) + notify_service.NewRelease(gitRepo.Ctx, rel) } return nil @@ -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 { - notification.NotifyUpdateRelease(gitRepo.Ctx, doer, rel) - return nil - } - if !rel.IsDraft { - notification.NotifyNewRelease(gitRepo.Ctx, rel) + 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) - notification.NotifyPushCommits( + 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()) - notification.NotifyDeleteRef(ctx, doer, repo, refName) + 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 } } - notification.NotifyDeleteRelease(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 805269413d..3d0681f1e1 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,15 +13,16 @@ 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" + "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestRelease_Create(t *testing.T) { @@ -30,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() @@ -108,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", @@ -136,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() @@ -156,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)) @@ -180,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)) @@ -204,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)) @@ -235,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) @@ -257,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 @@ -268,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) @@ -279,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 0000000000..dae2b70f76 --- /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 f95fb5988f..b337eac38a 100644 --- a/services/repository/adopt.go +++ b/services/repository/adopt.go @@ -17,17 +17,19 @@ 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/notification" + "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" + notify_service "code.gitea.io/gitea/services/notify" "github.com/gobwas/glob" ) // AdoptRepository adopts pre-existing repository files for the user/organization. -func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts repo_module.CreateRepoOptions) (*repo_model.Repository, error) { +func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) { if !doer.IsAdmin && !u.CanCreateRepo() { return nil, repo_model.ErrReachLimitOfRepo{ Limit: u.MaxRepoCreation, @@ -104,7 +106,7 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts repo_mo return nil, err } - notification.NotifyAdoptRepository(ctx, doer, u, repo) + notify_service.AdoptRepository(ctx, doer, u, repo) return repo, nil } @@ -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,6 +188,17 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r return fmt.Errorf("updateRepository: %w", err) } + // 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) + } + return nil } @@ -255,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 2e3defee8d..01c58f0ce4 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 4b6fb7446d..ec6e9dfac3 100644 --- a/services/repository/archiver/archiver_test.go +++ b/services/repository/archiver/archiver_test.go @@ -5,30 +5,30 @@ 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/test" + "code.gitea.io/gitea/services/contexttest" + + _ "code.gitea.io/gitea/models/actions" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } func TestArchive_Basic(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - ctx, _ := test.MockContext(t, "user27/repo49") + ctx, _ := contexttest.MockContext(t, "user27/repo49") firstCommit, secondCommit := "51f84af23134", "aacbdfe9e1c4" - test.LoadRepo(t, ctx, 49) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 49) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") @@ -80,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) @@ -101,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 @@ -109,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 11a8b20531..229ac54f30 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -10,48 +10,37 @@ 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/notification" + "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" "xorm.io/builder" ) // 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) + dbBranches, totalNumOfBranches, err := db.FindAndCount[git_model.Branch](ctx, branchOpts) if err != nil { return nil, nil, 0, err } - branchOpts.ExcludeBranchNames = []string{repo.DefaultBranch} - - dbBranches, err := git_model.FindBranches(ctx, branchOpts) - if err != nil { + if err := git_model.BranchList(dbBranches).LoadDeletedBy(ctx); err != nil { return nil, nil, 0, err } - - if err := 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 } @@ -301,8 +436,8 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m return "", err } - notification.NotifyDeleteRef(ctx, doer, repo, git.RefNameFromBranch(from)) - notification.NotifyCreateRef(ctx, doer, repo, refNameTo, refID) + notify_service.DeleteRef(ctx, doer, repo, git.RefNameFromBranch(from)) + notify_service.CreateRef(ctx, doer, repo, refNameTo, refID) return "", nil } @@ -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 91351cbf49..b0811a99fc 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 84fdb7159b..5cdcc14679 100644 --- a/services/repository/check.go +++ b/services/repository/check.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" @@ -92,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) @@ -165,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 := models.DeleteRepository(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) @@ -193,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 new file mode 100644 index 0000000000..4a43ae2a28 --- /dev/null +++ b/services/repository/collaboration.go @@ -0,0 +1,58 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +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(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) { + collaboration := &repo_model.Collaboration{ + RepoID: repo.ID, + UserID: collaborator.ID, + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + 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 + } + + if err = access_model.RecalculateAccesses(ctx, repo); err != nil { + return err + } + + if err = repo_model.WatchRepo(ctx, collaborator, repo, false); err != nil { + return err + } + + 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, collaborator); err != nil { + return err + } + + return committer.Commit() +} diff --git a/models/repo_collaboration_test.go b/services/repository/collaboration_test.go similarity index 67% rename from models/repo_collaboration_test.go rename to services/repository/collaboration_test.go index 95fb35fe6d..a2eb06b81a 100644 --- a/models/repo_collaboration_test.go +++ b/services/repository/collaboration_test.go @@ -1,7 +1,7 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package models +package repository import ( "testing" @@ -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(repo, 4)) - unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) + + 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 2497910a83..e8c0262ef4 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 0000000000..145fc7d53c --- /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 0000000000..7c9f535ae0 --- /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 0000000000..3801a5eee4 --- /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 new file mode 100644 index 0000000000..971793bcc6 --- /dev/null +++ b/services/repository/create.go @@ -0,0 +1,318 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "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/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" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/util" +) + +// 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 + ObjectFormatName string +} + +func prepareRepoCommit(ctx context.Context, repo *repo_model.Repository, tmpDir, repoPath string, opts CreateRepoOptions) error { + commitTimeStr := time.Now().Format(time.RFC3339) + authorSig := repo.Owner.NewGitSig() + + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+authorSig.Name, + "GIT_AUTHOR_EMAIL="+authorSig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+authorSig.Name, + "GIT_COMMITTER_EMAIL="+authorSig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + + // Clone to temporary path and do the init commit. + if stdout, _, err := git.NewCommand(ctx, "clone").AddDynamicArguments(repoPath, tmpDir). + SetDescription(fmt.Sprintf("prepareRepoCommit (git clone): %s to %s", repoPath, tmpDir)). + RunStdString(&git.RunOpts{Dir: "", Env: env}); err != nil { + log.Error("Failed to clone from %v into %s: stdout: %s\nError: %v", repo, tmpDir, stdout, err) + return fmt.Errorf("git clone: %w", err) + } + + // README + data, err := options.Readme(opts.Readme) + if err != nil { + return fmt.Errorf("GetRepoInitFile[%s]: %w", opts.Readme, err) + } + + cloneLink := repo.CloneLink() + match := map[string]string{ + "Name": repo.Name, + "Description": repo.Description, + "CloneURL.SSH": cloneLink.SSH, + "CloneURL.HTTPS": cloneLink.HTTPS, + "OwnerName": repo.OwnerName, + } + res, err := vars.Expand(string(data), match) + if err != nil { + // here we could just log the error and continue the rendering + log.Error("unable to expand template vars for repo README: %s, err: %v", opts.Readme, err) + } + if err = os.WriteFile(filepath.Join(tmpDir, "README.md"), + []byte(res), 0o644); err != nil { + return fmt.Errorf("write README.md: %w", err) + } + + // .gitignore + if len(opts.Gitignores) > 0 { + var buf bytes.Buffer + names := strings.Split(opts.Gitignores, ",") + for _, name := range names { + data, err = options.Gitignore(name) + if err != nil { + return fmt.Errorf("GetRepoInitFile[%s]: %w", name, err) + } + buf.WriteString("# ---> " + name + "\n") + buf.Write(data) + buf.WriteString("\n") + } + + if buf.Len() > 0 { + if err = os.WriteFile(filepath.Join(tmpDir, ".gitignore"), buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write .gitignore: %w", err) + } + } + } + + // LICENSE + if len(opts.License) > 0 { + data, err = repo_module.GetLicense(opts.License, &repo_module.LicenseValues{ + Owner: repo.OwnerName, + Email: authorSig.Email, + Repo: repo.Name, + Year: time.Now().Format("2006"), + }) + if err != nil { + return fmt.Errorf("getLicense[%s]: %w", opts.License, err) + } + + if err = os.WriteFile(filepath.Join(tmpDir, "LICENSE"), data, 0o644); err != nil { + return fmt.Errorf("write LICENSE: %w", err) + } + } + + return nil +} + +// 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, opts.ObjectFormatName); err != nil { + return err + } + + // Initialize repository according to user's choice. + if opts.AutoInit { + tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name) + if err != nil { + return fmt.Errorf("Failed to create temp dir for repository %s: %w", repo.RepoPath(), err) + } + defer func() { + if err := util.RemoveAll(tmpDir); err != nil { + log.Warn("Unable to remove temporary directory: %s: Error: %v", tmpDir, err) + } + }() + + if err = prepareRepoCommit(ctx, repo, tmpDir, repoPath, opts); err != nil { + return fmt.Errorf("prepareRepoCommit: %w", err) + } + + // Apply changes and commit. + if err = initRepoCommit(ctx, tmpDir, repo, u, opts.DefaultBranch); err != nil { + return fmt.Errorf("initRepoCommit: %w", err) + } + } + + // Re-fetch the repository from database before updating it (else it would + // override changes that were done earlier with sql) + if repo, err = repo_model.GetRepositoryByID(ctx, repo.ID); err != nil { + return fmt.Errorf("getRepositoryByID: %w", err) + } + + if !opts.AutoInit { + repo.IsEmpty = true + } + + repo.DefaultBranch = setting.Repository.DefaultBranch + repo.DefaultWikiBranch = setting.Repository.DefaultBranch + + if len(opts.DefaultBranch) > 0 { + repo.DefaultBranch = opts.DefaultBranch + if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { + return fmt.Errorf("setDefaultBranch: %w", err) + } + + if !repo.IsEmpty { + if _, err := repo_module.SyncRepoBranches(ctx, repo.ID, u.ID); err != nil { + return fmt.Errorf("SyncRepoBranches: %w", err) + } + } + } + + if err = UpdateRepository(ctx, repo, false); err != nil { + return fmt.Errorf("updateRepository: %w", err) + } + + return nil +} + +// CreateRepositoryDirectly creates a repository for the user/organization. +func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) { + if !doer.IsAdmin && !u.CanCreateRepo() { + return nil, repo_model.ErrReachLimitOfRepo{ + Limit: u.MaxRepoCreation, + } + } + + if len(opts.DefaultBranch) == 0 { + opts.DefaultBranch = setting.Repository.DefaultBranch + } + + // Check if label template exist + if len(opts.IssueLabels) > 0 { + if _, err := repo_module.LoadTemplateLabelsByDisplayName(opts.IssueLabels); err != nil { + return nil, err + } + } + + if opts.ObjectFormatName == "" { + opts.ObjectFormatName = git.Sha1ObjectFormat.Name() + } + + repo := &repo_model.Repository{ + OwnerID: u.ID, + Owner: u, + OwnerName: u.Name, + Name: opts.Name, + LowerName: strings.ToLower(opts.Name), + Description: opts.Description, + OriginalURL: opts.OriginalURL, + OriginalServiceType: opts.GitServiceType, + IsPrivate: opts.IsPrivate, + IsFsckEnabled: !opts.IsMirror, + IsTemplate: opts.IsTemplate, + CloseIssuesViaCommitInAnyBranch: setting.Repository.DefaultCloseIssuesViaCommitsInAnyBranch, + Status: opts.Status, + IsEmpty: !opts.AutoInit, + TrustModel: opts.TrustModel, + IsMirror: opts.IsMirror, + DefaultBranch: opts.DefaultBranch, + DefaultWikiBranch: setting.Repository.DefaultBranch, + ObjectFormatName: opts.ObjectFormatName, + } + + var rollbackRepo *repo_model.Repository + + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := repo_module.CreateRepositoryByExample(ctx, doer, u, repo, false, false); err != nil { + return err + } + + // No need for init mirror. + if opts.IsMirror { + return nil + } + + repoPath := repo_model.RepoPath(u.Name, repo.Name) + isExist, err := util.IsExist(repoPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", repoPath, err) + return err + } + if isExist { + // repo already exists - We have two or three options. + // 1. We fail stating that the directory exists + // 2. We create the db repository to go with this data and adopt the git repo + // 3. We delete it and start afresh + // + // Previously Gitea would just delete and start afresh - this was naughty. + // So we will now fail and delegate to other functionality to adopt or delete + log.Error("Files already exist in %s and we are not going to adopt or delete.", repoPath) + return repo_model.ErrRepoFilesAlreadyExist{ + Uname: u.Name, + Name: repo.Name, + } + } + + if err = initRepository(ctx, repoPath, doer, repo, opts); err != nil { + if err2 := util.RemoveAll(repoPath); err2 != nil { + log.Error("initRepository: %v", err) + return fmt.Errorf( + "delete repo directory %s/%s failed(2): %v", u.Name, repo.Name, err2) + } + return fmt.Errorf("initRepository: %w", err) + } + + // Initialize Issue Labels if selected + if len(opts.IssueLabels) > 0 { + if err = repo_module.InitializeLabels(ctx, repo.ID, opts.IssueLabels, false); err != nil { + rollbackRepo = repo + rollbackRepo.OwnerID = u.ID + return fmt.Errorf("InitializeLabels: %w", err) + } + } + + if err := repo_module.CheckDaemonExportOK(ctx, repo); err != nil { + return fmt.Errorf("checkDaemonExportOK: %w", err) + } + + if stdout, _, err := git.NewCommand(ctx, "update-server-info"). + SetDescription(fmt.Sprintf("CreateRepository(git update-server-info): %s", repoPath)). + RunStdString(&git.RunOpts{Dir: repoPath}); err != nil { + log.Error("CreateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", repo, stdout, err) + rollbackRepo = repo + rollbackRepo.OwnerID = u.ID + return fmt.Errorf("CreateRepository(git update-server-info): %w", err) + } + return nil + }); err != nil { + if rollbackRepo != nil { + if errDelete := DeleteRepositoryDirectly(ctx, doer, rollbackRepo.ID); errDelete != nil { + log.Error("Rollback deleteRepository: %v", errDelete) + } + } + + return nil, err + } + + return repo, nil +} diff --git a/services/repository/create_test.go b/services/repository/create_test.go new file mode 100644 index 0000000000..41e6b615db --- /dev/null +++ b/services/repository/create_test.go @@ -0,0 +1,148 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "fmt" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestIncludesAllRepositoriesTeams(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + 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 { + if rid > 0 { + assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i) + } + } + } + + // Get an admin user. + user, err := user_model.GetUserByID(db.DefaultContext, 1) + assert.NoError(t, err, "GetUserByID") + + // Create org. + org := &organization.Organization{ + Name: "All_repo", + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: structs.VisibleTypePublic, + } + assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization") + + // Check Owner team. + ownerTeam, err := org.GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err, "GetOwnerTeam") + assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") + + // Create repos. + 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) + } + } + // Get fresh copy of Owner team after creating repos. + ownerTeam, err = org.GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err, "GetOwnerTeam") + + // Create teams and check repositories. + teams := []*organization.Team{ + ownerTeam, + { + OrgID: org.ID, + Name: "team one", + AccessMode: perm.AccessModeRead, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 2", + AccessMode: perm.AccessModeRead, + IncludesAllRepositories: false, + }, + { + OrgID: org.ID, + Name: "team three", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 4", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: false, + }, + } + teamRepos := [][]int64{ + repoIDs, + repoIDs, + {}, + repoIDs, + {}, + } + for i, team := range teams { + if i > 0 { // first team is Owner. + assert.NoError(t, models.NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name) + } + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Update teams and check repositories. + teams[3].IncludesAllRepositories = false + teams[4].IncludesAllRepositories = true + 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]) + } + + // Create repo and check teams repositories. + 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) + } + 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, 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 { + if i > 0 { // first repo already deleted. + 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 new file mode 100644 index 0000000000..8d6729f31b --- /dev/null +++ b/services/repository/delete.go @@ -0,0 +1,455 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models" + actions_model "code.gitea.io/gitea/models/actions" + activities_model "code.gitea.io/gitea/models/activities" + admin_model "code.gitea.io/gitea/models/admin" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "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" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + system_model "code.gitea.io/gitea/models/system" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + actions_module "code.gitea.io/gitea/modules/actions" + "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, repoID int64, ignoreOrgTeams ...bool) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + 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 := 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 := 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 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 := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID}) + if err != nil { + return fmt.Errorf("listDeployKeys: %w", err) + } + needRewriteKeysFile := len(deployKeys) > 0 + for _, dKey := range deployKeys { + if err := models.DeleteDeployKey(ctx, doer, dKey.ID); err != nil { + return fmt.Errorf("deleteDeployKeys: %w", err) + } + } + + if cnt, err := sess.ID(repoID).Delete(&repo_model.Repository{}); err != nil { + return err + } else if cnt != 1 { + return repo_model.ErrRepoNotExist{ + ID: repoID, + OwnerName: "", + Name: "", + } + } + + if org != nil && org.IsOrganization() { + teams, err := organization.FindOrgTeams(ctx, org.ID) + if err != nil { + return err + } + for _, t := range teams { + if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID) { + continue + } else if err = removeRepositoryFromTeam(ctx, t, repo, false); err != nil { + return err + } + } + } + + attachments := make([]*repo_model.Attachment, 0, 20) + if err = sess.Join("INNER", "`release`", "`release`.id = `attachment`.release_id"). + Where("`release`.repo_id = ?", repoID). + Find(&attachments); err != nil { + return err + } + releaseAttachments := make([]string, 0, len(attachments)) + for i := 0; i < len(attachments); i++ { + releaseAttachments = append(releaseAttachments, attachments[i].RelativePath()) + } + + if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars=num_stars-1 WHERE id IN (SELECT `uid` FROM `star` WHERE repo_id = ?)", repo.ID); err != nil { + return err + } + + if _, err := db.GetEngine(ctx).In("hook_id", builder.Select("id").From("webhook").Where(builder.Eq{"webhook.repo_id": repo.ID})). + Delete(&webhook.HookTask{}); err != nil { + return err + } + + if err := db.DeleteBeans(ctx, + &access_model.Access{RepoID: repo.ID}, + &activities_model.Action{RepoID: repo.ID}, + &repo_model.Collaboration{RepoID: repoID}, + &issues_model.Comment{RefRepoID: repoID}, + &git_model.CommitStatus{RepoID: repoID}, + &git_model.Branch{RepoID: repoID}, + &git_model.LFSLock{RepoID: repoID}, + &repo_model.LanguageStat{RepoID: repoID}, + &issues_model.Milestone{RepoID: repoID}, + &repo_model.Mirror{RepoID: repoID}, + &activities_model.Notification{RepoID: repoID}, + &git_model.ProtectedBranch{RepoID: repoID}, + &git_model.ProtectedTag{RepoID: repoID}, + &repo_model.PushMirror{RepoID: repoID}, + &repo_model.Release{RepoID: repoID}, + &repo_model.RepoIndexerStatus{RepoID: repoID}, + &repo_model.Redirect{RedirectRepoID: repoID}, + &repo_model.RepoUnit{RepoID: repoID}, + &repo_model.Star{RepoID: repoID}, + &admin_model.Task{RepoID: repoID}, + &repo_model.Watch{RepoID: repoID}, + &webhook.Webhook{RepoID: repoID}, + &secret_model.Secret{RepoID: repoID}, + &actions_model.ActionTaskStep{RepoID: repoID}, + &actions_model.ActionTask{RepoID: repoID}, + &actions_model.ActionRunJob{RepoID: repoID}, + &actions_model.ActionRun{RepoID: repoID}, + &actions_model.ActionRunner{RepoID: repoID}, + &actions_model.ActionScheduleSpec{RepoID: repoID}, + &actions_model.ActionSchedule{RepoID: repoID}, + &actions_model.ActionArtifact{RepoID: repoID}, + ); err != nil { + return fmt.Errorf("deleteBeans: %w", err) + } + + // Delete Labels and related objects + if err := issues_model.DeleteLabelsByRepoID(ctx, repoID); err != nil { + return err + } + + // Delete Pulls and related objects + if err := issues_model.DeletePullsByBaseRepoID(ctx, repoID); err != nil { + return err + } + + // Delete Issues and related objects + var attachmentPaths []string + if attachmentPaths, err = issues_model.DeleteIssuesByRepoID(ctx, repoID); err != nil { + return err + } + + // Delete issue index + if err := db.DeleteResourceIndex(ctx, "issue_index", repoID); err != nil { + return err + } + + if repo.IsFork { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_forks=num_forks-1 WHERE id=?", repo.ForkID); err != nil { + return fmt.Errorf("decrease fork count: %w", err) + } + } + + if _, err := db.Exec(ctx, "UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", repo.OwnerID); err != nil { + return err + } + + if len(repo.Topics) > 0 { + if err := repo_model.RemoveTopicsFromRepo(ctx, repo.ID); err != nil { + return err + } + } + + if err := project_model.DeleteProjectByRepoID(ctx, repoID); err != nil { + return fmt.Errorf("unable to delete projects for repo[%d]: %w", repoID, err) + } + + // Remove LFS objects + var lfsObjects []*git_model.LFSMetaObject + if err = sess.Where("repository_id=?", repoID).Find(&lfsObjects); err != nil { + return err + } + + lfsPaths := make([]string, 0, len(lfsObjects)) + for _, v := range lfsObjects { + count, err := db.CountByBean(ctx, &git_model.LFSMetaObject{Pointer: lfs.Pointer{Oid: v.Oid}}) + if err != nil { + return err + } + if count > 1 { + continue + } + + lfsPaths = append(lfsPaths, v.RelativePath()) + } + + if _, err := db.DeleteByBean(ctx, &git_model.LFSMetaObject{RepositoryID: repoID}); err != nil { + return err + } + + // Remove archives + var archives []*repo_model.RepoArchiver + if err = sess.Where("repo_id=?", repoID).Find(&archives); err != nil { + return err + } + + archivePaths := make([]string, 0, len(archives)) + for _, v := range archives { + archivePaths = append(archivePaths, v.RelativePath()) + } + + if _, err := db.DeleteByBean(ctx, &repo_model.RepoArchiver{RepoID: repoID}); err != nil { + return err + } + + if repo.NumForks > 0 { + if _, err = sess.Exec("UPDATE `repository` SET fork_id=0,is_fork=? WHERE fork_id=?", false, repo.ID); err != nil { + log.Error("reset 'fork_id' and 'is_fork': %v", err) + } + } + + // Get all attachments with both issue_id and release_id are zero + var newAttachments []*repo_model.Attachment + if err := sess.Where(builder.Eq{ + "repo_id": repo.ID, + "issue_id": 0, + "release_id": 0, + }).Find(&newAttachments); err != nil { + return err + } + + newAttachmentPaths := make([]string, 0, len(newAttachments)) + for _, attach := range newAttachments { + newAttachmentPaths = append(newAttachmentPaths, attach.RelativePath()) + } + + if _, err := sess.Where("repo_id=?", repo.ID).Delete(new(repo_model.Attachment)); err != nil { + return err + } + + if err = committer.Commit(); err != nil { + return err + } + + committer.Close() + + if needRewriteKeysFile { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { + log.Error("RewriteAllPublicKeys failed: %v", err) + } + } + + // We should always delete the files after the database transaction succeed. If + // we delete the file but the database rollback, the repository will be broken. + + // Remove repository files. + repoPath := repo.RepoPath() + system_model.RemoveAllWithNotice(ctx, "Delete repository files", repoPath) + + // Remove wiki files + if repo.HasWiki() { + system_model.RemoveAllWithNotice(ctx, "Delete repository wiki", repo.WikiPath()) + } + + // Remove archives + for _, archive := range archivePaths { + system_model.RemoveStorageWithNotice(ctx, storage.RepoArchives, "Delete repo archive file", archive) + } + + // Remove lfs objects + for _, lfsObj := range lfsPaths { + system_model.RemoveStorageWithNotice(ctx, storage.LFS, "Delete orphaned LFS file", lfsObj) + } + + // Remove issue attachment files. + for _, attachment := range attachmentPaths { + system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", attachment) + } + + // Remove release attachment files. + for _, releaseAttachment := range releaseAttachments { + system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete release attachment", releaseAttachment) + } + + // Remove attachment with no issue_id and release_id. + for _, newAttachment := range newAttachmentPaths { + system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", newAttachment) + } + + if len(repo.Avatar) > 0 { + if err := storage.RepoAvatars.Delete(repo.CustomAvatarRelativePath()); err != nil { + return fmt.Errorf("Failed to remove %s: %w", repo.Avatar, err) + } + } + + // Finally, delete action logs after the actions have already been deleted to avoid new log files + for _, task := range tasks { + err := actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + log.Error("remove log file %q: %v", task.LogFilename, err) + // go on + } + } + + // delete actions artifacts in ObjectStorage after the repo have already been deleted + for _, art := range artifacts { + if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil { + log.Error("remove artifact file %q: %v", art.StoragePath, err) + // go on + } + } + + return nil +} + +// removeRepositoryFromTeam removes a repository from a team and recalculates access +// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted) +func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) { + e := db.GetEngine(ctx) + if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil { + return err + } + + t.NumRepos-- + if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil { + return err + } + + // Don't need to recalculate when delete a repository from organization. + if recalculate { + if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil { + return err + } + } + + teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + TeamID: t.ID, + }) + if err != nil { + return fmt.Errorf("GetTeamMembers: %w", err) + } + 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, member, repo, false); err != nil { + return err + } + + // Remove all IssueWatches a user has subscribed to in the repositories + if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil { + return err + } + } + + return nil +} + +// HasRepository returns true if given repository belong to team. +func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool { + return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID) +} + +// RemoveRepositoryFromTeam removes repository from team of organization. +// If the team shall include all repositories the request is ignored. +func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error { + if !HasRepository(ctx, t, repoID) { + return nil + } + + if t.IncludesAllRepositories { + return nil + } + + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil { + return err + } + + 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 new file mode 100644 index 0000000000..869b8af11d --- /dev/null +++ b/services/repository/delete_test.go @@ -0,0 +1,55 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "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" +) + +func TestTeam_HasRepository(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID, repoID int64, expected bool) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + assert.Equal(t, expected, repo_service.HasRepository(db.DefaultContext, team, repoID)) + } + test(1, 1, false) + test(1, 3, true) + test(1, 5, true) + test(1, unittest.NonexistentID, false) + + test(2, 3, true) + test(2, 5, false) +} + +func TestTeam_RemoveRepository(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(teamID, repoID int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + 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}) + } + testSuccess(2, 3) + 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 c1c5bfb617..613b46d8f6 100644 --- a/services/repository/files/cherry_pick.go +++ b/services/repository/files/cherry_pick.go @@ -31,12 +31,15 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod log.Error("%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 3e4627487b..e0dad29273 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 30d62fbcdf..95e7c7087c 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 8ff96822c9..4811f9d327 100644 --- a/services/repository/files/content_test.go +++ b/services/repository/files/content_test.go @@ -4,22 +4,20 @@ 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/git" + "code.gitea.io/gitea/modules/gitrepo" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + _ "code.gitea.io/gitea/models/actions" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", "..", ".."), - }) + unittest.MainTest(m) } func getExpectedReadmeContentsResponse() *api.ContentsResponse { @@ -54,12 +52,12 @@ func getExpectedReadmeContentsResponse() *api.ContentsResponse { func TestGetContents(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() treePath := "README.md" @@ -82,12 +80,12 @@ func TestGetContents(t *testing.T) { func TestGetContentsOrListForDir(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() treePath := "" // root dir @@ -117,12 +115,12 @@ func TestGetContentsOrListForDir(t *testing.T) { func TestGetContentsOrListForFile(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() treePath := "README.md" @@ -145,12 +143,12 @@ func TestGetContentsOrListForFile(t *testing.T) { func TestGetContentsErrors(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() repo := ctx.Repo.Repository @@ -176,12 +174,12 @@ func TestGetContentsErrors(t *testing.T) { func TestGetContentsOrListErrors(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() repo := ctx.Repo.Repository @@ -207,11 +205,11 @@ func TestGetContentsOrListErrors(t *testing.T) { func TestGetContentsOrListOfEmptyRepos(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user30/empty") + ctx, _ := contexttest.MockContext(t, "user30/empty") ctx.SetParams(":id", "52") - test.LoadRepo(t, ctx, 52) - test.LoadUser(t, ctx, 30) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 52) + contexttest.LoadUser(t, ctx, 30) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() repo := ctx.Repo.Repository @@ -225,18 +223,18 @@ func TestGetContentsOrListOfEmptyRepos(t *testing.T) { func TestGetBlobBySHA(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" 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 373249b114..bf8b938e21 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 0346e0e9e9..63aff9b0e3 100644 --- a/services/repository/files/diff_test.go +++ b/services/repository/files/diff_test.go @@ -9,7 +9,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/json" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/gitdiff" "github.com/stretchr/testify/assert" @@ -17,12 +17,12 @@ import ( func TestGetDiffPreview(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() branch := ctx.Repo.Repository.DefaultBranch @@ -139,12 +139,12 @@ func TestGetDiffPreview(t *testing.T) { func TestGetDiffPreviewErrors(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() branch := ctx.Repo.Repository.DefaultBranch diff --git a/services/repository/files/file_test.go b/services/repository/files/file_test.go index d14a049438..a5b3aad91e 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/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/test" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) @@ -98,18 +98,18 @@ func getExpectedFileResponse() *api.FileResponse { func TestGetFileResponseFromCommit(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") + ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx.SetParams(":id", "1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() 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 fdf0b32f1a..f6d5643dc9 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 } @@ -108,7 +114,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user log.Error("%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 7f6b8137ae..9fcd335c55 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 0b1d304845..e3a7f3b8b0 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 51a2190e8f..508f20090d 100644 --- a/services/repository/files/tree_test.go +++ b/services/repository/files/tree_test.go @@ -8,18 +8,18 @@ import ( "code.gitea.io/gitea/models/unittest" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) func TestGetTreeBySHA(t *testing.T) { unittest.PrepareTestEnv(t) - ctx, _ := test.MockContext(t, "user2/repo1") - test.LoadRepo(t, ctx, 1) - test.LoadRepoCommit(t, ctx) - test.LoadUser(t, ctx, 2) - test.LoadGitRepo(t, ctx) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() sha := ctx.Repo.Repository.DefaultBranch diff --git a/services/repository/files/update.go b/services/repository/files/update.go index f01092d360..4f7178184b 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 } @@ -141,7 +147,7 @@ func ChangeRepoFiles(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 { 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 338811f0f1..cbfaf49d13 100644 --- a/services/repository/files/upload.go +++ b/services/repository/files/upload.go @@ -10,7 +10,6 @@ import ( "path" "strings" - "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" @@ -35,13 +34,13 @@ type uploadInfo struct { lfsMetaObject *git_model.LFSMetaObject } -func cleanUpAfterFailure(infos *[]uploadInfo, t *TemporaryUploadRepository, original error) error { +func cleanUpAfterFailure(ctx context.Context, infos *[]uploadInfo, t *TemporaryUploadRepository, original error) error { for _, info := range *infos { if info.lfsMetaObject == nil { continue } if !info.lfsMetaObject.Existing { - if _, err := git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, t.repo.ID, info.lfsMetaObject.Oid); err != nil { + if _, err := git_model.RemoveLFSMetaObjectByOid(ctx, t.repo.ID, info.lfsMetaObject.Oid); err != nil { original = fmt.Errorf("%w, %v", original, err) // We wrap the original error - as this is the underlying error that required the fallback } } @@ -55,7 +54,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use return nil } - uploads, err := repo_model.GetUploadsByUUIDs(opts.Files) + uploads, err := repo_model.GetUploadsByUUIDs(ctx, opts.Files) if err != nil { return fmt.Errorf("GetUploadsByUUIDs [uuids: %v]: %w", opts.Files, err) } @@ -88,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 @@ -144,10 +143,10 @@ 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(&infos, t, err) + return cleanUpAfterFailure(ctx, &infos, t, err) } // Don't move the files yet - we need to ensure that // everything can be inserted first @@ -158,7 +157,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use contentStore := lfs.NewContentStore() for _, info := range infos { if err := uploadToLFSContentStore(info, contentStore); err != nil { - return cleanUpAfterFailure(&infos, t, err) + return cleanUpAfterFailure(ctx, &infos, t, err) } } @@ -167,7 +166,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use return err } - return repo_model.DeleteUploads(uploads...) + return repo_model.DeleteUploads(ctx, uploads...) } func copyUploadedLFSFileIntoRepository(info *uploadInfo, filename2attribute2info map[string]map[string]string, t *TemporaryUploadRepository, treePath string) error { diff --git a/services/repository/fork.go b/services/repository/fork.go index 59aa173373..f074fd1082 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -14,11 +14,12 @@ 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/notification" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" ) // ErrForkAlreadyExist represents a "ForkAlreadyExist" kind of error. @@ -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,21 +195,21 @@ 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) } } - notification.NotifyForkRepository(ctx, doer, opts.BaseRepo, repo) + notify_service.ForkRepository(ctx, doer, opts.BaseRepo, repo) return repo, nil } diff --git a/modules/repository/generate.go b/services/repository/generate.go similarity index 89% rename from modules/repository/generate.go rename to services/repository/generate.go index 2e0b7600a5..9b09e271ab 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 } @@ -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 8506fa3413..97e9e290a3 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 0000000000..817fa4abd7 --- /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 8e654b6f13..4d48881b87 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 e88befdfef..ee0b8f6b89 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 007790f2a9..7ad1540aee 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 0000000000..df5cc67ae1 --- /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 68ff172ea3..39843249a5 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -11,20 +11,20 @@ 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/notification" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -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 { @@ -119,16 +120,16 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } tagName := opts.RefFullName.TagName() if opts.IsDelRef() { - notification.NotifyPushCommits( + notify_service.PushCommits( ctx, pusher, repo, &repo_module.PushUpdateOptions{ RefFullName: git.RefNameFromTag(tagName), OldCommitID: opts.OldCommitID, - NewCommitID: git.EmptySHA, + NewCommitID: objectFormat.EmptyObjectID().String(), }, repo_module.NewPushCommits()) delTags = append(delTags, tagName) - notification.NotifyDeleteRef(ctx, pusher, repo, opts.RefFullName) + notify_service.DeleteRef(ctx, pusher, repo, opts.RefFullName) } else { // is new tag newCommit, err := gitRepo.GetCommit(opts.NewCommitID) if err != nil { @@ -137,18 +138,18 @@ 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) - notification.NotifyPushCommits( + notify_service.PushCommits( ctx, pusher, repo, &repo_module.PushUpdateOptions{ RefFullName: opts.RefFullName, - OldCommitID: git.EmptySHA, + OldCommitID: objectFormat.EmptyObjectID().String(), NewCommitID: opts.NewCommitID, }, commits) addTags = append(addTags, tagName) - notification.NotifyCreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID) + notify_service.CreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID) } } else if opts.RefFullName.IsBranch() { if pusher == nil || pusher.ID != opts.PusherID { @@ -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 } @@ -197,7 +198,7 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { if err != nil { return fmt.Errorf("newCommit.CommitsBeforeLimit: %w", err) } - notification.NotifyCreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID) + notify_service.CreateRef(ctx, pusher, repo, opts.RefFullName, opts.NewCommitID) } else { l, err = newCommit.CommitsBeforeUntil(opts.OldCommitID) if err != nil { @@ -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,26 +263,18 @@ 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) - } - - notification.NotifyPushCommits(ctx, pusher, repo, opts, commits) + notify_service.PushCommits(ctx, pusher, repo, opts, commits) // Cache for big repository if err := CacheRef(graceful.GetManager().HammerContext(), repo, gitRepo, opts.RefFullName); err != nil { log.Error("repo_module.CacheRef %s/%s failed: %v", repo.ID, branch, err) } } else { - notification.NotifyDeleteRef(ctx, pusher, repo, opts.RefFullName) + notify_service.DeleteRef(ctx, pusher, repo, opts.RefFullName) if err = pull_service.CloseBranchPulls(ctx, pusher, repo.ID, branch); err != nil { // 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 ade747582f..d28200c0ad 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -7,7 +7,6 @@ import ( "context" "fmt" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" @@ -19,10 +18,10 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -40,14 +39,14 @@ type WebSearchResults struct { } // CreateRepository creates a repository for the user/organization. -func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts repo_module.CreateRepoOptions) (*repo_model.Repository, error) { - repo, err := repo_module.CreateRepository(doer, owner, opts) +func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts CreateRepoOptions) (*repo_model.Repository, error) { + repo, err := CreateRepositoryDirectly(ctx, doer, owner, opts) if err != nil { // No need to rollback here we should do this in CreateRepository... return nil, err } - notification.NotifyCreateRepository(ctx, doer, owner, repo) + notify_service.CreateRepository(ctx, doer, owner, repo) return repo, nil } @@ -60,10 +59,10 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod if notify { // If the repo itself has webhooks, we need to trigger them before deleting it... - notification.NotifyDeleteRepository(ctx, doer, repo) + notify_service.DeleteRepository(ctx, doer, repo) } - if err := models.DeleteRepository(doer, repo.OwnerID, repo.ID); err != nil { + if err := DeleteRepositoryDirectly(ctx, doer, repo.ID); err != nil { return err } @@ -84,7 +83,7 @@ func PushCreateRepo(ctx context.Context, authUser, owner *user_model.User, repoN } } - repo, err := CreateRepository(ctx, authUser, owner, repo_module.CreateRepoOptions{ + repo, err := CreateRepository(ctx, authUser, owner, CreateRepoOptions{ Name: repoName, IsPrivate: setting.Repository.DefaultPushCreatePrivate, }) @@ -96,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 0000000000..b82f24271e --- /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 9a69360aff..36a680c8e2 100644 --- a/services/repository/template.go +++ b/services/repository/template.go @@ -11,8 +11,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/notification" - repo_module "code.gitea.io/gitea/modules/repository" + notify_service "code.gitea.io/gitea/services/notify" ) // GenerateIssueLabels generates issue labels from a template repository @@ -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 } } @@ -130,7 +129,7 @@ func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templ return nil, err } - notification.NotifyCreateRepository(ctx, doer, owner, generateRepo) + notify_service.CreateRepository(ctx, doer, owner, generateRepo) return generateRepo, nil } diff --git a/services/repository/transfer.go b/services/repository/transfer.go index b9b26f314c..83d3032188 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -6,17 +6,22 @@ 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" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" 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" ) // repoWorkingPool represents a working pool to order the parallel changes to the same repository @@ -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 } @@ -54,11 +59,283 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep } } - notification.NotifyTransferRepository(ctx, doer, repo, oldOwner.Name) + notify_service.TransferRepository(ctx, doer, repo, oldOwner.Name) 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,14 +347,14 @@ 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 } repoWorkingPool.CheckOut(fmt.Sprint(repo.ID)) repo.Name = newRepoName - notification.NotifyRenameRepository(ctx, doer, repo, oldRepoName) + notify_service.RenameRepository(ctx, doer, repo, oldRepoName) return nil } @@ -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) @@ -126,7 +407,28 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use } // notify users who are able to accept / reject transfer - notification.NotifyRepoPendingTransfer(ctx, doer, newOwner, repo) + notify_service.RepoPendingTransfer(ctx, doer, newOwner, repo) 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 1299e66be2..c3f03d6638 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" @@ -14,9 +15,9 @@ 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/notification" - "code.gitea.io/gitea/modules/notification/action" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/feed" + notify_service "code.gitea.io/gitea/services/notify" "github.com/stretchr/testify/assert" ) @@ -25,7 +26,7 @@ var notifySync sync.Once func registerNotifier() { notifySync.Do(func() { - notification.RegisterNotifier(action.NewNotifier()) + notify_service.RegisterNotifier(feed.NewNotifier()) }) } @@ -42,7 +43,7 @@ func TestTransferOwnership(t *testing.T) { transferredRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) assert.EqualValues(t, 2, transferredRepo.OwnerID) - exist, err := util.IsExist(repo_model.RepoPath("user3", "repo3")) + exist, err := util.IsExist(repo_model.RepoPath("org3", "repo3")) assert.NoError(t, err) assert.False(t, exist) exist, err = util.IsExist(repo_model.RepoPath("user2", "repo3")) @@ -52,7 +53,7 @@ func TestTransferOwnership(t *testing.T) { OpType: activities_model.ActionTransferRepo, ActUserID: 2, RepoID: 3, - Content: "user3/repo3", + Content: "org3/repo3", }) unittest.CheckConsistencyFor(t, &repo_model.Repository{}, &user_model.User{}, &organization.Team{}) @@ -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 new file mode 100644 index 0000000000..031c474dd7 --- /dev/null +++ b/services/secrets/secrets.go @@ -0,0 +1,83 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "context" + + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" +) + +func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*secret_model.Secret, bool, error) { + if err := ValidateName(name); err != nil { + return nil, false, err + } + + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ + OwnerID: ownerID, + RepoID: repoID, + Name: name, + }) + if err != nil { + return nil, false, err + } + + if len(s) == 0 { + s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data) + if err != nil { + return nil, false, err + } + return s, true, nil + } + + if err := secret_model.UpdateSecret(ctx, s[0].ID, data); err != nil { + return nil, false, err + } + + return s[0], false, nil +} + +func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error { + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ + OwnerID: ownerID, + RepoID: repoID, + SecretID: secretID, + }) + if err != nil { + return err + } + if len(s) != 1 { + return secret_model.ErrSecretNotFound{} + } + + return deleteSecret(ctx, s[0]) +} + +func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error { + if err := ValidateName(name); err != nil { + return err + } + + s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ + OwnerID: ownerID, + RepoID: repoID, + Name: name, + }) + if err != nil { + return err + } + if len(s) != 1 { + return secret_model.ErrSecretNotFound{} + } + + return deleteSecret(ctx, s[0]) +} + +func deleteSecret(ctx context.Context, s *secret_model.Secret) error { + if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil { + return err + } + return nil +} diff --git a/services/secrets/validation.go b/services/secrets/validation.go new file mode 100644 index 0000000000..3db5b96452 --- /dev/null +++ b/services/secrets/validation.go @@ -0,0 +1,25 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secrets + +import ( + "regexp" + + "code.gitea.io/gitea/modules/util" +) + +// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets +var ( + namePattern = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") + forbiddenPrefixPattern = regexp.MustCompile("(?i)^GIT(EA|HUB)_") + + ErrInvalidName = util.NewInvalidArgumentErrorf("invalid secret name") +) + +func ValidateName(name string) error { + if !namePattern.MatchString(name) || forbiddenPrefixPattern.MatchString(name) { + return ErrInvalidName + } + return nil +} diff --git a/services/task/migrate.go b/services/task/migrate.go index 52b6220a04..9cef77a6c8 100644 --- a/services/task/migrate.go +++ b/services/task/migrate.go @@ -4,6 +4,7 @@ package task import ( + "context" "errors" "fmt" "strings" @@ -17,12 +18,12 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/migration" - "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/migrations" + notify_service "code.gitea.io/gitea/services/notify" ) func handleCreateError(owner *user_model.User, err error) error { @@ -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 { - notification.NotifyMigrateRepository(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 db5c1dd3f8..e15cab7b3c 100644 --- a/services/task/task.go +++ b/services/task/task.go @@ -4,9 +4,11 @@ package task import ( + "context" "fmt" admin_model "code.gitea.io/gitea/models/admin" + "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/graceful" @@ -14,22 +16,22 @@ import ( "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/queue" - repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + repo_service "code.gitea.io/gitea/services/repository" ) // taskQueue is a global queue of tasks 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) } @@ -47,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) } } @@ -55,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 } @@ -65,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) @@ -96,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_module.CreateRepository(doer, u, repo_module.CreateRepoOptions{ + repo, err := repo_service.CreateRepositoryDirectly(ctx, doer, u, repo_service.CreateRepoOptions{ Name: opts.RepoName, Description: opts.Description, OriginalURL: opts.OriginalURL, @@ -112,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()) } @@ -120,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 } @@ -128,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 @@ -143,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/modules/notification/ui/ui.go b/services/uinotification/notify.go similarity index 68% rename from modules/notification/ui/ui.go rename to services/uinotification/notify.go index 2ca1a7700f..be5f7019a2 100644 --- a/modules/notification/ui/ui.go +++ b/services/uinotification/notify.go @@ -1,7 +1,7 @@ // Copyright 2018 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package ui +package uinotification import ( "context" @@ -14,13 +14,13 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/queue" + notify_service "code.gitea.io/gitea/services/notify" ) type ( notificationService struct { - base.NullNotifier + notify_service.NullNotifier issueQueue *queue.WorkerPoolQueue[issueNotificationOpts] } @@ -32,10 +32,16 @@ type ( } ) -var _ base.Notifier = ¬ificationService{} +func Init() error { + notify_service.RegisterNotifier(NewNotifier()) + + return nil +} + +var _ notify_service.Notifier = ¬ificationService{} // NewNotifier create a new notificationService notifier -func NewNotifier() base.Notifier { +func NewNotifier() notify_service.Notifier { ns := ¬ificationService{} ns.issueQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "notification-service", handler) if ns.issueQueue == nil { @@ -46,7 +52,7 @@ func NewNotifier() base.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) } } @@ -57,7 +63,7 @@ func (ns *notificationService) Run() { go graceful.GetManager().RunWithCancel(ns.issueQueue) // TODO: using "go" here doesn't seem right, just leave it as old code } -func (ns *notificationService) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, +func (ns *notificationService) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { opts := issueNotificationOpts{ @@ -81,7 +87,7 @@ func (ns *notificationService) NotifyCreateIssueComment(ctx context.Context, doe } } -func (ns *notificationService) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { +func (ns *notificationService) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { _ = ns.issueQueue.Push(issueNotificationOpts{ IssueID: issue.ID, NotificationAuthorID: issue.Poster.ID, @@ -95,7 +101,7 @@ func (ns *notificationService) NotifyNewIssue(ctx context.Context, issue *issues } } -func (ns *notificationService) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { +func (ns *notificationService) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { _ = ns.issueQueue.Push(issueNotificationOpts{ IssueID: issue.ID, NotificationAuthorID: doer.ID, @@ -103,12 +109,12 @@ func (ns *notificationService) NotifyIssueChangeStatus(ctx context.Context, doer }) } -func (ns *notificationService) NotifyIssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { +func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { if err := issue.LoadPullRequest(ctx); err != nil { 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, @@ -116,18 +122,18 @@ func (ns *notificationService) NotifyIssueChangeTitle(ctx context.Context, doer } } -func (ns *notificationService) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +func (ns *notificationService) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { _ = ns.issueQueue.Push(issueNotificationOpts{ IssueID: pr.Issue.ID, NotificationAuthorID: doer.ID, }) } -func (ns *notificationService) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - ns.NotifyMergePullRequest(ctx, doer, pr) +func (ns *notificationService) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ns.MergePullRequest(ctx, doer, pr) } -func (ns *notificationService) NotifyNewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { +func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { if err := pr.LoadIssue(ctx); err != nil { log.Error("Unable to load issue: %d for pr: %d: Error: %v", pr.IssueID, pr.ID, err) return @@ -162,7 +168,7 @@ func (ns *notificationService) NotifyNewPullRequest(ctx context.Context, pr *iss } } -func (ns *notificationService) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, c *issues_model.Comment, mentions []*user_model.User) { +func (ns *notificationService) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, c *issues_model.Comment, mentions []*user_model.User) { opts := issueNotificationOpts{ IssueID: pr.Issue.ID, NotificationAuthorID: r.Reviewer.ID, @@ -184,7 +190,7 @@ func (ns *notificationService) NotifyPullRequestReview(ctx context.Context, pr * } } -func (ns *notificationService) NotifyPullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) { +func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) { for _, mention := range mentions { _ = ns.issueQueue.Push(issueNotificationOpts{ IssueID: pr.Issue.ID, @@ -195,7 +201,7 @@ func (ns *notificationService) NotifyPullRequestCodeComment(ctx context.Context, } } -func (ns *notificationService) NotifyPullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { +func (ns *notificationService) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { opts := issueNotificationOpts{ IssueID: pr.IssueID, NotificationAuthorID: doer.ID, @@ -204,7 +210,7 @@ func (ns *notificationService) NotifyPullRequestPushCommits(ctx context.Context, _ = ns.issueQueue.Push(opts) } -func (ns *notificationService) NotifyPullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { +func (ns *notificationService) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { opts := issueNotificationOpts{ IssueID: review.IssueID, NotificationAuthorID: doer.ID, @@ -213,7 +219,7 @@ func (ns *notificationService) NotifyPullReviewDismiss(ctx context.Context, doer _ = ns.issueQueue.Push(opts) } -func (ns *notificationService) NotifyIssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { +func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { if !removed && doer.ID != assignee.ID { opts := issueNotificationOpts{ IssueID: issue.ID, @@ -229,7 +235,7 @@ func (ns *notificationService) NotifyIssueChangeAssignee(ctx context.Context, do } } -func (ns *notificationService) NotifyPullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { +func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { if isRequest { opts := issueNotificationOpts{ IssueID: issue.ID, @@ -245,7 +251,7 @@ func (ns *notificationService) NotifyPullRequestReviewRequest(ctx context.Contex } } -func (ns *notificationService) NotifyRepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { +func (ns *notificationService) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { err := db.WithTx(ctx, func(ctx context.Context) error { return activities_model.CreateRepoTransferNotification(ctx, doer, newOwner, repo) }) diff --git a/services/user/avatar.go b/services/user/avatar.go index 26c100abdb..2d6c3faf9a 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 0000000000..0b3b618aae --- /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 0000000000..aec3e03cf3 --- /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 01e3c37b39..212cb83e03 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 0000000000..5c0de708e9 --- /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 0000000000..b40f86b6a6 --- /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 0000000000..cbaf90053a --- /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 0000000000..7ed764b539 --- /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 bb3dd002ea..2287e36c71 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,8 +23,11 @@ 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" ) // RenameUser renames a user @@ -39,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 { @@ -58,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) @@ -127,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. @@ -157,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 := models.DeleteRepository(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 @@ -188,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, @@ -203,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) @@ -222,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 } @@ -262,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 3f1bf9a0f8..bd6019a14f 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 } @@ -92,7 +92,7 @@ func TestCreateUser(t *testing.T) { MustChangePassword: false, } - assert.NoError(t, user_model.CreateUser(user)) + assert.NoError(t, user_model.CreateUser(db.DefaultContext, user)) assert.NoError(t, DeleteUser(db.DefaultContext, user, false)) } @@ -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) @@ -177,7 +177,7 @@ func TestCreateUser_Issue5882(t *testing.T) { for _, v := range tt { setting.Admin.DisableRegularOrgCreation = v.disableOrgCreation - assert.NoError(t, user_model.CreateUser(v.user)) + assert.NoError(t, user_model.CreateUser(db.DefaultContext, v.user)) u, err := user_model.GetUserByEmail(db.DefaultContext, v.user.Email) assert.NoError(t, err) @@ -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 fd7a3d7fba..b2c0a73784 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 ee63975ad3..d0cfc1598f 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 69fae03299..c57d04415a 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 e3122d2f36..25f47347d0 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 204587eca5..659754d5e0 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()) + return d.createPayload(p.Sender, text, "", p.Package.HTMLURL, color), nil +} + +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) } - s.Username = discord.Username - s.AvatarURL = discord.IconURL - - return convertPayloader(s, p, event) + 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 624d53446a..c04b95383b 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 089f51952f..1ec436894b 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 a3182e82b0..ef18333fd4 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 986467bc99..69b944f4bd 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 64bd72f5a0..41bac3fd04 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 0189e17840..756b9db230 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" @@ -12,13 +11,13 @@ import ( "code.gitea.io/gitea/modules/setting" _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" ) 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 df97b43b64..0329804a8b 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{} + +type matrixConvertor struct { + MsgType string } -// MatrixLinkFormatter creates a link compatible with Matrix -func MatrixLinkFormatter(url, text string) string { - return fmt.Sprintf(`%s`, html.EscapeString(url), html.EscapeString(text)) +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 } -// 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) - } -} - -// 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 8c71094228..058f8e3c5f 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 6ad58a6247..99d0106184 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 4f378713cc..01e08b918e 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 23080a5a35..1ab14fd6a7 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -14,31 +14,30 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/notification" - "code.gitea.io/gitea/modules/notification/base" "code.gitea.io/gitea/modules/repository" "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" + notify_service "code.gitea.io/gitea/services/notify" ) func init() { - notification.RegisterNotifier(&webhookNotifier{}) + notify_service.RegisterNotifier(&webhookNotifier{}) } type webhookNotifier struct { - base.NullNotifier + notify_service.NullNotifier } -var _ base.Notifier = &webhookNotifier{} +var _ notify_service.Notifier = &webhookNotifier{} // NewNotifier create a new webhookNotifier notifier -func NewNotifier() base.Notifier { +func NewNotifier() notify_service.Notifier { return &webhookNotifier{} } -func (m *webhookNotifier) NotifyIssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { +func (m *webhookNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { if err := issue.LoadPoster(ctx); err != nil { log.Error("LoadPoster: %v", err) return @@ -78,7 +77,7 @@ func (m *webhookNotifier) NotifyIssueClearLabels(ctx context.Context, doer *user } } -func (m *webhookNotifier) NotifyForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { +func (m *webhookNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { oldPermission, _ := access_model.GetUserRepoPermission(ctx, oldRepo, doer) permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer) @@ -106,7 +105,7 @@ func (m *webhookNotifier) NotifyForkRepository(ctx context.Context, doer *user_m } } -func (m *webhookNotifier) NotifyCreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +func (m *webhookNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { // Add to hook queue for created repo after session commit. if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{ Action: api.HookRepoCreated, @@ -118,7 +117,7 @@ func (m *webhookNotifier) NotifyCreateRepository(ctx context.Context, doer, u *u } } -func (m *webhookNotifier) NotifyDeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { +func (m *webhookNotifier) DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) { if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{ Action: api.HookRepoDeleted, Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), @@ -129,7 +128,7 @@ func (m *webhookNotifier) NotifyDeleteRepository(ctx context.Context, doer *user } } -func (m *webhookNotifier) NotifyMigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { +func (m *webhookNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { // Add to hook queue for created repo after session commit. if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventRepository, &api.RepositoryPayload{ Action: api.HookRepoCreated, @@ -141,7 +140,7 @@ func (m *webhookNotifier) NotifyMigrateRepository(ctx context.Context, doer, u * } } -func (m *webhookNotifier) NotifyIssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { +func (m *webhookNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { if issue.IsPull { permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) @@ -186,7 +185,7 @@ func (m *webhookNotifier) NotifyIssueChangeAssignee(ctx context.Context, doer *u } } -func (m *webhookNotifier) NotifyIssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { +func (m *webhookNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) var err error if issue.IsPull { @@ -226,7 +225,7 @@ func (m *webhookNotifier) NotifyIssueChangeTitle(ctx context.Context, doer *user } } -func (m *webhookNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { +func (m *webhookNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster) var err error if issue.IsPull { @@ -268,7 +267,7 @@ func (m *webhookNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *use } } -func (m *webhookNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { +func (m *webhookNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { if err := issue.LoadRepo(ctx); err != nil { log.Error("issue.LoadRepo: %v", err) return @@ -290,7 +289,7 @@ func (m *webhookNotifier) NotifyNewIssue(ctx context.Context, issue *issues_mode } } -func (m *webhookNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { +func (m *webhookNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) { if err := pull.LoadIssue(ctx); err != nil { log.Error("pull.LoadIssue: %v", err) return @@ -316,7 +315,7 @@ func (m *webhookNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues } } -func (m *webhookNotifier) NotifyIssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { +func (m *webhookNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) { if err := issue.LoadRepo(ctx); err != nil { log.Error("LoadRepo: %v", err) return @@ -360,7 +359,7 @@ func (m *webhookNotifier) NotifyIssueChangeContent(ctx context.Context, doer *us } } -func (m *webhookNotifier) NotifyUpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { +func (m *webhookNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { if err := c.LoadPoster(ctx); err != nil { log.Error("LoadPoster: %v", err) return @@ -400,7 +399,7 @@ func (m *webhookNotifier) NotifyUpdateComment(ctx context.Context, doer *user_mo } } -func (m *webhookNotifier) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, +func (m *webhookNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { var eventType webhook_module.HookEventType @@ -423,7 +422,7 @@ func (m *webhookNotifier) NotifyCreateIssueComment(ctx context.Context, doer *us } } -func (m *webhookNotifier) NotifyDeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { +func (m *webhookNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) { var err error if err = comment.LoadPoster(ctx); err != nil { @@ -460,7 +459,7 @@ func (m *webhookNotifier) NotifyDeleteComment(ctx context.Context, doer *user_mo } } -func (m *webhookNotifier) NotifyNewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { +func (m *webhookNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { // Add to hook queue for created wiki page. if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{ Action: api.HookWikiCreated, @@ -473,7 +472,7 @@ func (m *webhookNotifier) NotifyNewWikiPage(ctx context.Context, doer *user_mode } } -func (m *webhookNotifier) NotifyEditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { +func (m *webhookNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) { // Add to hook queue for edit wiki page. if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{ Action: api.HookWikiEdited, @@ -486,7 +485,7 @@ func (m *webhookNotifier) NotifyEditWikiPage(ctx context.Context, doer *user_mod } } -func (m *webhookNotifier) NotifyDeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { +func (m *webhookNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) { // Add to hook queue for edit wiki page. if err := PrepareWebhooks(ctx, EventSource{Repository: repo}, webhook_module.HookEventWiki, &api.WikiPayload{ Action: api.HookWikiDeleted, @@ -498,7 +497,7 @@ func (m *webhookNotifier) NotifyDeleteWikiPage(ctx context.Context, doer *user_m } } -func (m *webhookNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, +func (m *webhookNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedLabels, removedLabels []*issues_model.Label, ) { var err error @@ -544,7 +543,7 @@ func (m *webhookNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *use } } -func (m *webhookNotifier) NotifyIssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { +func (m *webhookNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) { var hookAction api.HookIssueAction var err error if issue.MilestoneID > 0 { @@ -586,7 +585,7 @@ func (m *webhookNotifier) NotifyIssueChangeMilestone(ctx context.Context, doer * } } -func (m *webhookNotifier) NotifyPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +func (m *webhookNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { apiPusher := convert.ToUser(ctx, pusher, nil) apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) if err != nil { @@ -610,12 +609,12 @@ func (m *webhookNotifier) NotifyPushCommits(ctx context.Context, pusher *user_mo } } -func (m *webhookNotifier) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { - // just redirect to the NotifyMergePullRequest - m.NotifyMergePullRequest(ctx, doer, pr) +func (m *webhookNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + // just redirect to the MergePullRequest + m.MergePullRequest(ctx, doer, pr) } -func (*webhookNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +func (*webhookNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { // Reload pull request information. if err := pr.LoadAttributes(ctx); err != nil { log.Error("LoadAttributes: %v", err) @@ -652,7 +651,7 @@ func (*webhookNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_m } } -func (m *webhookNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { +func (m *webhookNotifier) PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { if err := pr.LoadIssue(ctx); err != nil { log.Error("LoadIssue: %v", err) return @@ -677,7 +676,7 @@ func (m *webhookNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Contex } } -func (m *webhookNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { +func (m *webhookNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { var reviewHookType webhook_module.HookEventType switch review.Type { @@ -718,9 +717,9 @@ func (m *webhookNotifier) NotifyPullRequestReview(ctx context.Context, pr *issue } } -func (m *webhookNotifier) NotifyPullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { +func (m *webhookNotifier) 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("NotifyPullRequestReviewRequest: issue is not a pull request: %v", issue.ID) + log.Warn("PullRequestReviewRequest: issue is not a pull request: %v", issue.ID) return } permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) @@ -746,7 +745,7 @@ func (m *webhookNotifier) NotifyPullRequestReviewRequest(ctx context.Context, do } } -func (m *webhookNotifier) NotifyCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { +func (m *webhookNotifier) CreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { apiPusher := convert.ToUser(ctx, pusher, nil) apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}) refName := refFullName.ShortName() @@ -762,7 +761,7 @@ func (m *webhookNotifier) NotifyCreateRef(ctx context.Context, pusher *user_mode } } -func (m *webhookNotifier) NotifyPullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { +func (m *webhookNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { if err := pr.LoadIssue(ctx); err != nil { log.Error("LoadIssue: %v", err) return @@ -783,7 +782,7 @@ func (m *webhookNotifier) NotifyPullRequestSynchronized(ctx context.Context, doe } } -func (m *webhookNotifier) NotifyDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { +func (m *webhookNotifier) DeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { apiPusher := convert.ToUser(ctx, pusher, nil) apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}) refName := refFullName.ShortName() @@ -816,19 +815,19 @@ func sendReleaseHook(ctx context.Context, doer *user_model.User, rel *repo_model } } -func (m *webhookNotifier) NotifyNewRelease(ctx context.Context, rel *repo_model.Release) { +func (m *webhookNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { sendReleaseHook(ctx, rel.Publisher, rel, api.HookReleasePublished) } -func (m *webhookNotifier) NotifyUpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { +func (m *webhookNotifier) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { sendReleaseHook(ctx, doer, rel, api.HookReleaseUpdated) } -func (m *webhookNotifier) NotifyDeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { +func (m *webhookNotifier) DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { sendReleaseHook(ctx, doer, rel, api.HookReleaseDeleted) } -func (m *webhookNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { +func (m *webhookNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { apiPusher := convert.ToUser(ctx, pusher, nil) apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) if err != nil { @@ -852,19 +851,19 @@ func (m *webhookNotifier) NotifySyncPushCommits(ctx context.Context, pusher *use } } -func (m *webhookNotifier) NotifySyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { - m.NotifyCreateRef(ctx, pusher, repo, refFullName, refID) +func (m *webhookNotifier) SyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) { + m.CreateRef(ctx, pusher, repo, refFullName, refID) } -func (m *webhookNotifier) NotifySyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { - m.NotifyDeleteRef(ctx, pusher, repo, refFullName) +func (m *webhookNotifier) SyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) { + m.DeleteRef(ctx, pusher, repo, refFullName) } -func (m *webhookNotifier) NotifyPackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { +func (m *webhookNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { notifyPackage(ctx, doer, pd, api.HookPackageCreated) } -func (m *webhookNotifier) NotifyPackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { +func (m *webhookNotifier) PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { notifyPackage(ctx, doer, pd, api.HookPackageDeleted) } diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index e47e7d3285..7880d8b606 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 +} - packagist := &PackagistMeta{} - if err := json.Unmarshal([]byte(meta), &packagist); err != nil { - return s, errors.New("GetPackagistPayload meta json:" + err.Error()) +type packagistConvertor struct { + PackageURL string +} + +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) } - s.PackagistRepository.URL = packagist.PackageURL - return convertPayloader(s, p, event) + pc := packagistConvertor{ + PackageURL: meta.PackageURL, + } + return newJSONRequest(pc, w, t, true) } diff --git a/services/webhook/packagist_test.go b/services/webhook/packagist_test.go index 932b56fe9b..e9b0695baa 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() - - pl, err := new(PackagistPayload).Push(p) + data, err := p.JSONPayload() require.NoError(t, err) - require.NotNil(t, pl) - require.IsType(t, &PackagistPayload{}, pl) - json, err := pl.JSONPayload() + 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) - assert.NotEmpty(t, json) + + 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) + + 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 d53e65fa5e..54a11a5868 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) } - return s, nil + 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 req, body, nil } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 0cb27bb3dd..ba8bac27d9 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) +type slackConvertor struct { + Channel string + Username string + IconURL string + Color string +} - slack := &SlackMeta{} - if err := json.Unmarshal([]byte(meta), &slack); err != nil { - return s, errors.New("GetSlackPayload meta json:" + err.Error()) +var _ payloadConvertor[SlackPayload] = slackConvertor{} + +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) } - - s.Channel = slack.Channel - s.Username = slack.Username - s.IconURL = slack.IconURL - s.Color = slack.Color - - return convertPayloader(s, p, event) + 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 d9828f374f..7ebf16aba2 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) - require.NotNil(t, pl) - require.IsType(t, &SlackPayload{}, pl) - assert.Equal(t, "[] Repository created by ", pl.(*SlackPayload).Text) + 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) + + 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 ea7e8185de..c2b4820032 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 b42b0ccda8..2fe5161b22 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 9d5dab85f7..e0e8fa2fc1 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 338b94360b..5f5c146232 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 a7680f1c67..46e7856ecf 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 c0183dd2b5..1b921a44bd 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 } @@ -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,7 +201,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model } else { commitTreeOpts.NoGPGSign = true } - if hasMasterBranch { + if hasDefaultBranch { commitTreeOpts.Parents = []string{"HEAD"} } @@ -206,7 +213,7 @@ func updateWikiPage(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, @@ -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,44 @@ 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) + } + + 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 e51d6c630c..74c7064043 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 85d99806fe..0a18cffa25 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,14 +12,15 @@ 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" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { - unittest.MainTest(m, &unittest.TestOptions{ - GiteaRootPath: filepath.Join("..", ".."), - }) + unittest.MainTest(m) } func TestWebPathSegments(t *testing.T) { @@ -165,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) @@ -211,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) @@ -235,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) @@ -248,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 @@ -274,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 @@ -297,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 2bd8c600cb..4c09a9d588 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -8,7 +8,7 @@ description: | icon: public/assets/img/logo.png confinement: strict -base: core18 +base: core22 adopt-info: gitea architectures: @@ -44,12 +44,13 @@ parts: source: . stage-packages: [ git, sqlite3, openssh-client ] build-packages: [ git, libpam0g-dev, libsqlite3-dev, build-essential] - build-snaps: [ go, node/18/stable ] + build-snaps: [ go/1.22/stable, node/20/stable ] build-environment: - LDFLAGS: "" override-pull: | - snapcraftctl pull + craftctl default + git config --global --add safe.directory /root/parts/gitea/src last_committed_tag="$(git for-each-ref --sort=taggerdate --format '%(tag)' refs/tags | tail -n 1)" last_released_tag="$(snap info gitea | awk '$1 == "latest/candidate:" { print $2 }')" # If the latest tag from the upstream project has not been released to @@ -61,8 +62,8 @@ parts: version="$(git describe --always | sed -e 's/-/+git/;y/-/./')" [ -n "$(echo $version | grep "+git")" ] && grade=devel || grade=stable - snapcraftctl set-version "$version" - snapcraftctl set-grade "$grade" + craftctl set version="$version" + craftctl set grade="$grade" override-build: | set -x diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 0000000000..523b18841e --- /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 0000000000..d49e9d7a1c --- /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 9640e0fd1f..597863d73b 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 a292051fd0..cbb66b1605 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 6dfa86d9dd..e140d6b5eb 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 c9f7c0d516..6483ec800c 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 37d1635c11..f130e18f65 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 a2bd37be0c..9754aed55a 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 aaa8b7fe2d..f02c5bdf30 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 e83f7afb69..31195acf65 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 9608616e1b..6a3f00f9a8 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 19977f05a9..0000000000 --- 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 36d9bcb8a5..8c16429920 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 0000000000..02ab5fd0fb --- /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 c154619435..3cb641488c 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 8312fba039..589fc5048a 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 153877174b..388863df9b 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}})

    -