mirror of
https://github.com/go-gitea/gitea
synced 2025-12-07 13:28:25 +00:00
Merge branch 'main' into allow-force-push-protected-branches
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
|
||||
+26
-2
@@ -12,6 +12,7 @@ plugins:
|
||||
- "@eslint-community/eslint-plugin-eslint-comments"
|
||||
- "@stylistic/eslint-plugin-js"
|
||||
- eslint-plugin-array-func
|
||||
- eslint-plugin-github
|
||||
- eslint-plugin-i
|
||||
- eslint-plugin-jquery
|
||||
- eslint-plugin-no-jquery
|
||||
@@ -209,6 +210,29 @@ rules:
|
||||
func-names: [0]
|
||||
func-style: [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]
|
||||
@@ -558,7 +582,6 @@ rules:
|
||||
prefer-rest-params: [2]
|
||||
prefer-spread: [2]
|
||||
prefer-template: [2]
|
||||
quotes: [2, single, {avoidEscape: true, allowTemplateLiterals: true}]
|
||||
radix: [2, as-needed]
|
||||
regexp/confusing-quantifier: [2]
|
||||
regexp/control-character-escape: [2]
|
||||
@@ -723,6 +746,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]
|
||||
@@ -810,7 +834,7 @@ rules:
|
||||
wc/no-constructor-params: [2]
|
||||
wc/no-constructor: [2]
|
||||
wc/no-customized-built-in-elements: [2]
|
||||
wc/no-exports-with-element: [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]
|
||||
|
||||
@@ -64,6 +64,18 @@ jobs:
|
||||
- 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
|
||||
|
||||
+12
-3
@@ -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"
|
||||
|
||||
+77
-77
@@ -1,7 +1,7 @@
|
||||
plugins:
|
||||
- stylelint-declaration-strict-value
|
||||
- stylelint-declaration-block-no-ignored-properties
|
||||
- stylelint-stylistic
|
||||
- "@stylistic/stylelint-plugin"
|
||||
|
||||
ignoreFiles:
|
||||
- "**/*.go"
|
||||
@@ -17,6 +17,82 @@ overrides:
|
||||
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": 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
|
||||
alpha-value-notation: null
|
||||
annotation-no-unknown: true
|
||||
at-rule-allowed-list: null
|
||||
@@ -137,82 +213,6 @@ rules:
|
||||
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
|
||||
|
||||
+28
-10
@@ -47,6 +47,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)
|
||||
@@ -167,7 +168,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) |
|
||||
|
||||
@@ -486,36 +487,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) <i@wolfogre.com>
|
||||
- [Lunny Xiao](https://gitea.com/lunny) <xiaolunwen@gmail.com>
|
||||
- [Matti Ranta](https://gitea.com/techknowlogick) <techknowlogick@gitea.io>
|
||||
- [Matti Ranta](https://gitea.com/techknowlogick) <techknowlogick@gitea.com>
|
||||
- Community
|
||||
- [6543](https://gitea.com/6543) <6543@obermui.de>
|
||||
- [Andrew Thornton](https://gitea.com/zeripath) <art27@cantab.net>
|
||||
- [delvh](https://gitea.com/delvh) <dev.lh@web.de>
|
||||
- [John Olheiser](https://gitea.com/jolheiser) <john.olheiser@gmail.com>
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM docker.io/library/golang:1.21-alpine3.19 AS build-env
|
||||
FROM docker.io/library/golang:1.22-alpine3.19 AS build-env
|
||||
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY ${GOPROXY:-direct}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM docker.io/library/golang:1.21-alpine3.19 AS build-env
|
||||
FROM docker.io/library/golang:1.22-alpine3.19 AS build-env
|
||||
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY ${GOPROXY:-direct}
|
||||
|
||||
@@ -23,19 +23,19 @@ 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.55.0
|
||||
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
|
||||
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.4.1
|
||||
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.30.5
|
||||
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
|
||||
@@ -146,6 +146,8 @@ 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
|
||||
|
||||
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) docs/content templates options/locale/locale_en-US.ini .github
|
||||
|
||||
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)
|
||||
@@ -219,6 +221,8 @@ help:
|
||||
@echo " - lint-swagger lint swagger files"
|
||||
@echo " - lint-templates lint template files"
|
||||
@echo " - lint-yaml lint yaml files"
|
||||
@echo " - lint-spell lint spelling"
|
||||
@echo " - lint-spell-fix lint spelling and fix issues"
|
||||
@echo " - checks run various consistency checks"
|
||||
@echo " - checks-frontend check frontend files"
|
||||
@echo " - checks-backend check backend files"
|
||||
@@ -308,10 +312,6 @@ fmt-check: fmt
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: misspell-check
|
||||
misspell-check:
|
||||
go run $(MISSPELL_PACKAGE) -error $(GO_DIRS) $(WEB_DIRS)
|
||||
|
||||
.PHONY: $(TAGS_EVIDENCE)
|
||||
$(TAGS_EVIDENCE):
|
||||
@mkdir -p $(MAKE_EVIDENCE_DIR)
|
||||
@@ -351,13 +351,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
|
||||
@@ -395,6 +395,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
|
||||
@@ -980,3 +988,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:
|
||||
|
||||
@@ -89,25 +89,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 <num>`) 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
|
||||
|
||||
@@ -178,5 +176,5 @@ Looking for an overview of the interface? Check it out!
|
||||
||||
|
||||
|:---:|:---:|:---:|
|
||||
||||
|
||||
|||
|
||||
|||
|
||||
||||
|
||||
||||
|
||||
|
||||
+2
-2
@@ -98,5 +98,5 @@ Fork -> Patch -> Push -> Pull Request
|
||||
||||
|
||||
|:---:|:---:|:---:|
|
||||
||||
|
||||
|||
|
||||
|||
|
||||
||||
|
||||
||||
|
||||
|
||||
@@ -79,4 +79,8 @@ async function main() {
|
||||
]);
|
||||
}
|
||||
|
||||
main().then(exit).catch(exit);
|
||||
try {
|
||||
exit(await main());
|
||||
} catch (err) {
|
||||
exit(err);
|
||||
}
|
||||
|
||||
@@ -63,4 +63,8 @@ async function main() {
|
||||
]);
|
||||
}
|
||||
|
||||
main().then(exit).catch(exit);
|
||||
try {
|
||||
exit(await main());
|
||||
} catch (err) {
|
||||
exit(err);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+7
-5
@@ -216,16 +216,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)
|
||||
|
||||
@@ -1044,7 +1044,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
|
||||
|
||||
@@ -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 = <OS_USERNAME>` 执行该命令;并且,为了让备份文件夹的压缩过程能够顺利执行,`docker exec` 命令必须在 `--tempdir` 内部执行。
|
||||
|
||||
示例:
|
||||
|
||||
```none
|
||||
docker exec -u <OS_USERNAME> -it -w <--tempdir> $(docker ps -qf 'name=^<NAME_OF_DOCKER_CONTAINER>$') bash -c '/usr/local/bin/gitea dump -c </path/to/app.ini>'
|
||||
```
|
||||
|
||||
\*注意:`--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
|
||||
```
|
||||
|
||||
@@ -95,6 +95,7 @@ Admin operations:
|
||||
- Options:
|
||||
- `--username value`, `-u value`: Username. Required.
|
||||
- `--password value`, `-p value`: New password. Required.
|
||||
- `--must-change-password`: If provided, the user is required to choose a new password after the login. Optional.
|
||||
- Examples:
|
||||
- `gitea admin user change-password --username myname --password asecurepassword`
|
||||
- `must-change-password`:
|
||||
|
||||
@@ -126,7 +126,7 @@ In addition, there is _`StaticRootPath`_ which can be set as a built-in at build
|
||||
keywords used in Pull Request comments to automatically close a related issue
|
||||
- `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen
|
||||
a related issue
|
||||
- `DEFAULT_MERGE_STYLE`: **merge**: Set default merge style for repository creating, valid options: `merge`, `rebase`, `rebase-merge`, `squash`
|
||||
- `DEFAULT_MERGE_STYLE`: **merge**: Set default merge style for repository creating, valid options: `merge`, `rebase`, `rebase-merge`, `squash`, `fast-forward-only`
|
||||
- `DEFAULT_MERGE_MESSAGE_COMMITS_LIMIT`: **50**: In the default merge message for squash commits include at most this many commits. Set to `-1` to include all commits
|
||||
- `DEFAULT_MERGE_MESSAGE_SIZE`: **5120**: In the default merge message for squash commits limit the size of the commit messages. Set to `-1` to have no limit. Only used if `POPULATE_SQUASH_COMMENT_WITH_COMMIT_MESSAGES` is `true`.
|
||||
- `DEFAULT_MERGE_MESSAGE_ALL_AUTHORS`: **false**: In the default merge message for squash commits walk all commits to include all authors in the Co-authored-by otherwise just use those in the limited list
|
||||
|
||||
@@ -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 文件中不起作用,仅在此处列出作为文档说明。
|
||||
|
||||
包含`#`或者`;`的变量必须使用引号(`` ` ``或者`""""`)包裹,否则会被解析为注释。
|
||||
|
||||
@@ -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`,否则仅使用限定列表中的作者。
|
||||
|
||||
@@ -48,6 +48,7 @@ HTML 页面由[Go HTML Template](https://pkg.go.dev/html/template)渲染。
|
||||
10. 避免在一个事件监听器中混合不同的事件,优先为每个事件使用独立的事件监听器。
|
||||
11. 推荐使用自定义事件名称前缀`ce-`。
|
||||
12. Gitea 的 tailwind-style CSS 类使用`gt-`前缀(`gt-relative`),而 Gitea 自身的私有框架级 CSS 类使用`g-`前缀(`g-modal-confirm`)。
|
||||
13. 尽量避免内联脚本和样式,建议将JS代码放入JS文件中并使用CSS类。如果内联脚本和样式不可避免,请解释无法避免的原因。
|
||||
|
||||
### 可访问性 / ARIA
|
||||
|
||||
@@ -64,18 +65,21 @@ Gitea使用一些补丁使Fomantic UI更具可访问性(参见`aria.js`和`ari
|
||||
|
||||
* Vue + Vanilla JS
|
||||
* Fomantic-UI(jQuery)
|
||||
* htmx (部分页面重新加载其他静态组件)
|
||||
* Vanilla JS
|
||||
|
||||
不推荐的实现方式:
|
||||
|
||||
* Vue + Fomantic-UI(jQuery)
|
||||
* jQuery + Vanilla JS
|
||||
* htmx + 任何其他需要大量 JavaScript 代码或不必要的功能,如 htmx 脚本 (`hx-on`)
|
||||
|
||||
为了保持界面一致,Vue 组件可以使用 Fomantic-UI 的 CSS 类。
|
||||
尽管不建议混合使用不同的框架,
|
||||
我们使用 htmx 进行简单的交互。您可以在此 [PR](https://github.com/go-gitea/gitea/pull/28908) 中查看一个简单交互的示例,其中应使用 htmx。如果您需要更高级的反应性,请不要使用 htmx,请使用其他框架(Vue/Vanilla JS)。
|
||||
但如果混合使用是必要的,并且代码设计良好且易于维护,也可以工作。
|
||||
|
||||
### async 函数
|
||||
### `async` 函数
|
||||
|
||||
只有当函数内部存在`await`调用或返回`Promise`时,才将函数标记为`async`。
|
||||
|
||||
@@ -91,6 +95,12 @@ Gitea使用一些补丁使Fomantic UI更具可访问性(参见`aria.js`和`ari
|
||||
这是有意为之的,我们想调用异步函数并忽略Promise。
|
||||
一些 lint 规则和 IDE 也会在未处理返回的 Promise 时发出警告。
|
||||
|
||||
### 获取数据
|
||||
|
||||
要获取数据,请使用`modules/fetch.js`中的包装函数`GET`、`POST`等。他们
|
||||
接受内容的`data`选项,将自动设置 CSRF 令牌并返回
|
||||
[Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)。
|
||||
|
||||
### HTML 属性和 dataset
|
||||
|
||||
禁止使用`dataset`,它的驼峰命名行为使得搜索属性变得困难。
|
||||
@@ -132,3 +142,7 @@ Gitea使用一些补丁使Fomantic UI更具可访问性(参见`aria.js`和`ari
|
||||
### Vue3 和 JSX
|
||||
|
||||
Gitea 现在正在使用 Vue3。我们决定不引入 JSX,以保持 HTML 代码和 JavaScript 代码分离。
|
||||
|
||||
### UI示例
|
||||
|
||||
Gitea 使用一些自制的 UI 元素并自定义其他元素,以将它们更好地集成到通用 UI 方法中。当在开发模式(`RUN_MODE=dev`)下运行 Gitea 时,在 `http(s)://your-gitea-url:port/devtest` 下会提供一个包含一些标准化 UI 示例的页面。
|
||||
|
||||
@@ -243,10 +243,10 @@ documentation using:
|
||||
make generate-swagger
|
||||
```
|
||||
|
||||
You should validate your generated Swagger file and spell-check it with:
|
||||
You should validate your generated Swagger file:
|
||||
|
||||
```bash
|
||||
make swagger-validate misspell-check
|
||||
make swagger-validate
|
||||
```
|
||||
|
||||
You should commit the changed swagger JSON file. The continuous integration
|
||||
|
||||
@@ -228,10 +228,10 @@ Gitea Logo的 PNG 和 SVG 版本是使用 `TAGS="gitea" make generate-images`
|
||||
make generate-swagger
|
||||
```
|
||||
|
||||
您应该验证生成的 Swagger 文件并使用以下命令对其进行拼写检查:
|
||||
您应该验证生成的 Swagger 文件:
|
||||
|
||||
```bash
|
||||
make swagger-validate misspell-check
|
||||
make swagger-validate
|
||||
```
|
||||
|
||||
您应该提交更改后的 swagger JSON 文件。持续集成服务器将使用以下方法检查是否已完成:
|
||||
|
||||
@@ -15,11 +15,64 @@ menu:
|
||||
identifier: "support"
|
||||
---
|
||||
|
||||
## 需要帮助?
|
||||
# 支持选项
|
||||
|
||||
如果您在使用或者开发过程中遇到问题,请到以下渠道咨询:
|
||||
- [付费商业支持](https://about.gitea.com/)
|
||||
- [Discord](https://discord.gg/Gitea)
|
||||
- [Discourse 论坛](https://discourse.gitea.io/)
|
||||
- [Matrix](https://matrix.to/#/#gitea-space:matrix.org)
|
||||
- 注意:大多数 Matrix 频道都与 Discord 中的对应频道桥接,可能在桥接过程中会出现一定程度的不稳定性。
|
||||
- 中文支持
|
||||
- [Discourse 中文分类](https://discourse.gitea.io/c/5-category/5)
|
||||
- QQ 群 328432459
|
||||
|
||||
- 到 [GitHub Issue](https://github.com/go-gitea/gitea/issues) 提问(因为项目维护人员来自世界各地,为保证沟通顺畅,请使用英文提问)
|
||||
- 中文问题到 [Gitea 论坛](https://discourse.gitea.io/c/5-category/5) 提问
|
||||
- 访问 [Discord Gitea 聊天室 - 英文](https://discord.gg/Gitea)
|
||||
- 加入 QQ群 328432459 获得进一步的支持
|
||||
# Bug 报告
|
||||
|
||||
如果您发现了 Bug,请在 GitHub 上 [创建一个问题](https://github.com/go-gitea/gitea/issues)。
|
||||
|
||||
**注意:** 在请求支持时,可能需要准备以下信息,以便帮助者获得所需的所有信息:
|
||||
|
||||
1. 您的 `app.ini`(将任何敏感数据进行必要的清除)。
|
||||
2. 您看到的任何错误消息。
|
||||
3. Gitea 日志以及与情况相关的所有其他日志。
|
||||
- 收集 `trace` / `debug` 级别的日志更有用(参见下一节)。
|
||||
- 在使用 systemd 时,使用 `journalctl --lines 1000 --unit gitea` 收集日志。
|
||||
- 在使用 Docker 时,使用 `docker logs --tail 1000 <gitea-container>` 收集日志。
|
||||
4. 可重现的步骤,以便他人能够更快速、更容易地重现和理解问题。
|
||||
- [try.gitea.io](https://try.gitea.io) 可用于重现问题。
|
||||
5. 如果遇到慢速/挂起/死锁等问题,请在出现问题时报告堆栈跟踪。
|
||||
转到 "Site Admin" -> "Monitoring" -> "Stacktrace" -> "Download diagnosis report"。
|
||||
|
||||
# 高级 Bug 报告提示
|
||||
|
||||
## 更多日志的配置选项
|
||||
|
||||
默认情况下,日志以 `info` 级别输出到控制台。
|
||||
如果您需要设置日志级别和/或从文件中收集日志,
|
||||
您只需将以下配置复制到您的 `app.ini` 中(删除所有其他 `[log]` 部分),
|
||||
然后您将在 Gitea 的日志目录中找到 `*.log` 文件(默认为 `%(GITEA_WORK_DIR)/log`)。
|
||||
|
||||
```ini
|
||||
; 要显示所有 SQL 日志,您还可以在 [database] 部分中设置 LOG_SQL=true
|
||||
[log]
|
||||
LEVEL=debug
|
||||
MODE=console,file
|
||||
```
|
||||
|
||||
## 使用命令行收集堆栈跟踪
|
||||
|
||||
Gitea 可以使用 Golang 的 pprof 处理程序和工具链来收集堆栈跟踪和其他运行时信息。
|
||||
|
||||
如果 Web UI 停止工作,您可以尝试通过命令行收集堆栈跟踪:
|
||||
|
||||
1. 设置 app.ini:
|
||||
|
||||
```
|
||||
[server]
|
||||
ENABLE_PPROF = true
|
||||
```
|
||||
|
||||
2. 重新启动 Gitea
|
||||
|
||||
3. 尝试触发bug,当请求卡住一段时间,使用或浏览器访问:获取堆栈跟踪。
|
||||
`curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=1`
|
||||
|
||||
@@ -17,7 +17,9 @@ menu:
|
||||
|
||||
# 数据库准备
|
||||
|
||||
在使用 Gitea 前,您需要准备一个数据库。Gitea 支持 PostgreSQL(>= 12)、MySQL(>= 8.0)、SQLite 和 MSSQL(>= 2012 SP4)这几种数据库。本页将指导您准备数据库。由于 PostgreSQL 和 MySQL 在生产环境中被广泛使用,因此本文档将仅涵盖这两种数据库。如果您计划使用 SQLite,则可以忽略本章内容。
|
||||
在使用 Gitea 前,您需要准备一个数据库。Gitea 支持 PostgreSQL(>= 12)、MySQL(>= 8.0)、MariaDB(>= 10.4)、SQLite(内置) 和 MSSQL(>= 2012 SP4)这几种数据库。本页将指导您准备数据库。由于 PostgreSQL 和 MySQL 在生产环境中被广泛使用,因此本文档将仅涵盖这两种数据库。如果您计划使用 SQLite,则可以忽略本章内容。
|
||||
|
||||
如果您使用不受支持的数据库版本,请通过 [联系我们](/help/support) 以获取有关我们的扩展支持的信息。我们可以为旧数据库提供测试和支持,并将这些修复集成到 Gitea 代码库中。
|
||||
|
||||
数据库实例可以与 Gitea 实例在相同机器上(本地数据库),也可以与 Gitea 实例在不同机器上(远程数据库)。
|
||||
|
||||
@@ -61,7 +63,9 @@ menu:
|
||||
|
||||
4. 使用 UTF-8 字符集和大小写敏感的排序规则创建数据库。
|
||||
|
||||
Gitea 启动后会尝试把数据库修改为更合适的字符集,如果你想指定自己的字符集规则,可以在 app.ini 中设置 `[database].CHARSET_COLLATION`。
|
||||
`utf8mb4_bin` 是 MySQL/MariaDB 的通用排序规则。
|
||||
Gitea 启动后会尝试把数据库修改为更合适的字符集 (`utf8mb4_0900_as_cs` 或者 `uca1400_as_cs`) 并在可能的情况下更改数据库。
|
||||
如果你想指定自己的字符集规则,可以在 `app.ini` 中设置 `[database].CHARSET_COLLATION`。
|
||||
|
||||
```sql
|
||||
CREATE DATABASE giteadb CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_bin';
|
||||
@@ -85,7 +89,7 @@ menu:
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
6. 通过 exit 退出数据库控制台。
|
||||
6. 通过 `exit` 退出数据库控制台。
|
||||
|
||||
7. 在您的 Gitea 服务器上,测试与数据库的连接:
|
||||
|
||||
@@ -93,13 +97,13 @@ menu:
|
||||
mysql -u gitea -h 203.0.113.3 -p giteadb
|
||||
```
|
||||
|
||||
其中 `gitea` 是数据库用户名,`giteadb` 是数据库名称,`203.0.113.3` 是数据库实例的 IP 地址。对于本地数据库,省略 -h 选项。
|
||||
其中 `gitea` 是数据库用户名,`giteadb` 是数据库名称,`203.0.113.3` 是数据库实例的 IP 地址。对于本地数据库,省略 `-h` 选项。
|
||||
|
||||
到此您应该能够连接到数据库了。
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
1. 对于远程数据库设置,通过编辑数据库实例上的 postgresql.conf 文件中的 listen_addresses 将 PostgreSQL 配置为监听您的 IP 地址:
|
||||
1. 对于远程数据库设置,通过编辑数据库实例上的 postgresql.conf 文件中的 `listen_addresses` 将 `PostgreSQL` 配置为监听您的 IP 地址:
|
||||
|
||||
```ini
|
||||
listen_addresses = 'localhost, 203.0.113.3'
|
||||
|
||||
@@ -27,13 +27,7 @@ Next, [install Node.js with npm](https://nodejs.org/en/download/) which is
|
||||
required to build the JavaScript and CSS files. The minimum supported Node.js
|
||||
version is @minNodeVersion@ and the latest LTS version is recommended.
|
||||
|
||||
**Note**: When executing make tasks that require external tools, like
|
||||
`make misspell-check`, Gitea will automatically download and build these as
|
||||
necessary. To be able to use these, you must have the `"$GOPATH/bin"` directory
|
||||
on the executable path. If you don't add the go bin directory to the
|
||||
executable path, you will have to manage this yourself.
|
||||
|
||||
**Note 2**: Go version @minGoVersion@ or higher is required. However, it is recommended to
|
||||
**Note**: Go version @minGoVersion@ or higher is required. However, it is recommended to
|
||||
obtain the same version as our continuous integration, see the advice given in
|
||||
[Hacking on Gitea](development/hacking-on-gitea.md)
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ menu:
|
||||
|
||||
接下来,[安装 Node.js 和 npm](https://nodejs.org/zh-cn/download/), 这是构建 JavaScript 和 CSS 文件所需的。最低支持的 Node.js 版本是 @minNodeVersion@,建议使用最新的 LTS 版本。
|
||||
|
||||
**注意**:当执行需要外部工具的 make 任务(如`make misspell-check`)时,Gitea 将根据需要自动下载和构建这些工具。为了能够实现这个目的,你必须将`"$GOPATH/bin"`目录添加到可执行路径中。如果没有将 Go 的二进制目录添加到可执行路径中,你需要自行解决产生的问题。
|
||||
|
||||
**注意2**:需要 Go 版本 @minGoVersion@ 或更高版本。不过,建议获取与我们的持续集成(continuous integration, CI)相同的版本,请参阅在 [Hacking on Gitea](development/hacking-on-gitea.md) 中给出的建议。
|
||||
**注意**:需要 Go 版本 @minGoVersion@ 或更高版本。不过,建议获取与我们的持续集成(continuous integration, CI)相同的版本,请参阅在 [Hacking on Gitea](development/hacking-on-gitea.md) 中给出的建议。
|
||||
|
||||
## 下载
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ menu:
|
||||
identifier: "windows-service"
|
||||
---
|
||||
|
||||
# 准备工作
|
||||
## 准备工作
|
||||
|
||||
在 C:\gitea\custom\conf\app.ini 中进行了以下更改:
|
||||
|
||||
@@ -27,7 +27,7 @@ RUN_USER = COMPUTERNAME$
|
||||
|
||||
COMPUTERNAME 是从命令行中运行 `echo %COMPUTERNAME%` 后得到的响应。如果响应是 `USER-PC`,那么 `RUN_USER = USER-PC$`。
|
||||
|
||||
## 使用绝对路径
|
||||
### 使用绝对路径
|
||||
|
||||
如果您使用 SQLite3,请将 `PATH` 更改为包含完整路径:
|
||||
|
||||
@@ -36,7 +36,7 @@ COMPUTERNAME 是从命令行中运行 `echo %COMPUTERNAME%` 后得到的响应
|
||||
PATH = c:/gitea/data/gitea.db
|
||||
```
|
||||
|
||||
# 注册为Windows服务
|
||||
## 注册为Windows服务
|
||||
|
||||
要注册为Windows服务,首先以Administrator身份运行 `cmd`,然后执行以下命令:
|
||||
|
||||
@@ -48,7 +48,16 @@ sc.exe create gitea start= auto binPath= "\"C:\gitea\gitea.exe\" web --config \"
|
||||
|
||||
之后在控制面板打开 "Windows Services",搜索 "gitea",右键选择 "Run"。在浏览器打开 `http://localhost:3000` 就可以访问了。(如果你修改了端口,请访问对应的端口,3000是默认端口)。
|
||||
|
||||
## 添加启动依赖项
|
||||
### 服务启动类型
|
||||
|
||||
据观察,在启动期间加载的系统上,Gitea 服务可能无法启动,并在 Windows 事件日志中记录超时。
|
||||
在这种情况下,将启动类型更改为`Automatic-Delayed`。这可以在服务创建期间完成,或者通过运行配置命令来完成。
|
||||
|
||||
```
|
||||
sc.exe config gitea start= delayed-auto
|
||||
```
|
||||
|
||||
### 添加启动依赖项
|
||||
|
||||
要将启动依赖项添加到 Gitea Windows 服务(例如 Mysql、Mariadb),作为管理员,然后运行以下命令:
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ A registration token can also be obtained from the gitea [command-line interface
|
||||
gitea --config /etc/gitea/app.ini actions generate-runner-token
|
||||
```
|
||||
|
||||
Tokens are valid for registering multiple runners, until they are revoked and replaced by a new token using the token reset link in the web interface.
|
||||
|
||||
### Register the runner
|
||||
|
||||
The act runner can be registered by running the following command:
|
||||
|
||||
@@ -132,7 +132,7 @@ Gitea Actions目前不支持此功能。
|
||||
如果你使用 `uses: actions/checkout@v4`,Gitea将会从 https://github.com/actions/checkout.git 下载这个 actions 项目。
|
||||
如果你想要从另外一个 Git服务下载actions,你只需要使用绝对URL `uses: https://gitea.com/actions/checkout@v4` 来下载。
|
||||
|
||||
如果你的 Gitea 实例是部署在一个互联网限制的网络中,有可以使用绝对地址来下载 actions。你也可以讲配置项修改为 `[actions].DEFAULT_ACTIONS_URL = self`。这样所有的相对路径的actions引用,将不再会从 github.com 去下载,而会从这个 Gitea 实例自己的仓库中去下载。例如: `uses: actions/checkout@v4` 将会从 `[server].ROOT_URL`/actions/checkout.git 这个地址去下载 actions。
|
||||
如果你的 Gitea 实例是部署在一个互联网限制的网络中,也可以使用绝对地址来下载 actions。你也可以将配置项修改为 `[actions].DEFAULT_ACTIONS_URL = self`。这样所有的相对路径的actions引用,将不再会从 github.com 去下载,而会从这个 Gitea 实例自己的仓库中去下载。例如: `uses: actions/checkout@v4` 将会从 `[server].ROOT_URL`/actions/checkout.git 这个地址去下载 actions。
|
||||
|
||||
设置`[actions].DEFAULT_ACTIONS_URL`进行配置。请参阅[配置备忘单](administration/config-cheat-sheet.md#actions-actions)。
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ It is always a bad idea to use a loopback address such as `127.0.0.1` or `localh
|
||||
If you are unsure which address to use, the LAN address is usually the right choice.
|
||||
|
||||
`token` is used for authentication and identification, such as `P2U1U0oB4XaRCi8azcngmPCLbRpUGapalhmddh23`.
|
||||
It is one-time use only and cannot be used to register multiple runners.
|
||||
You can obtain different levels of 'tokens' from the following places to create the corresponding level of' runners':
|
||||
Each token can be used to create multiple runners, until it is replaced with a new token using the reset link.
|
||||
You can obtain different levels of 'tokens' from the following places to create the corresponding level of 'runners':
|
||||
|
||||
- Instance level: The admin settings page, like `<your_gitea.com>/admin/actions/runners`.
|
||||
- Organization level: The organization settings page, like `<your_gitea.com>/<org>/settings/actions/runners`.
|
||||
|
||||
@@ -102,7 +102,7 @@ DELETE https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repositor
|
||||
| `branch` | The branch to use. |
|
||||
| `repository` | The repository to use. |
|
||||
| `architecture` | The package architecture. |
|
||||
| `filename` | The file to delete.
|
||||
| `filename` | The file to delete. |
|
||||
|
||||
Example request using HTTP Basic authentication:
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ Images must follow this naming convention:
|
||||
|
||||
`{registry}/{owner}/{image}`
|
||||
|
||||
When building your docker image, using the naming convention above, this looks like:
|
||||
|
||||
```shell
|
||||
# build an image with tag
|
||||
docker build -t {registry}/{owner}/{image}:{tag} .
|
||||
# name an existing image with tag
|
||||
docker tag {some-existing-image}:{tag} {registry}/{owner}/{image}:{tag}
|
||||
```
|
||||
|
||||
where your registry is the domain of your gitea instance (e.g. gitea.example.com).
|
||||
For example, these are all valid image names for the owner `testuser`:
|
||||
|
||||
`gitea.example.com/testuser/myimage`
|
||||
|
||||
@@ -42,7 +42,7 @@ The following package managers are currently supported:
|
||||
| [PyPI](usage/packages/pypi.md) | Python | `pip`, `twine` |
|
||||
| [RPM](usage/packages/rpm.md) | - | `yum`, `dnf`, `zypper` |
|
||||
| [RubyGems](usage/packages/rubygems.md) | Ruby | `gem`, `Bundler` |
|
||||
| [Swift](usage/packages/rubygems.md) | Swift | `swift` |
|
||||
| [Swift](usage/packages/swift.md) | Swift | `swift` |
|
||||
| [Vagrant](usage/packages/vagrant.md) | - | `vagrant` |
|
||||
|
||||
**The following paragraphs only apply if Packages are not globally disabled!**
|
||||
|
||||
@@ -26,7 +26,8 @@ To work with the Swift package registry, you need to use [swift](https://www.swi
|
||||
To register the package registry and provide credentials, execute:
|
||||
|
||||
```shell
|
||||
swift package-registry set https://gitea.example.com/api/packages/{owner}/swift -login {username} -password {password}
|
||||
swift package-registry set https://gitea.example.com/api/packages/{owner}/swift
|
||||
swift package-registry login https://gitea.example.com/api/packages/{owner}/swift --username {username} --password {password}
|
||||
```
|
||||
|
||||
| Placeholder | Description |
|
||||
|
||||
@@ -15,6 +15,10 @@ menu:
|
||||
|
||||
# 个人资料 README
|
||||
|
||||
要在您的 Gitea 个人资料页面显示一个 Markdown 文件,只需创建一个名为 ".profile" 的仓库,并编辑其中的 README.md 文件。Gitea 将自动获取该文件并在您的仓库上方显示。
|
||||
要在您的 Gitea 个人资料页面显示一个 Markdown 文件,只需创建一个名为 `.profile` 的仓库,并编辑其中的 `README.md` 文件。Gitea 将自动获取该文件并在您的仓库上方显示。
|
||||
|
||||
注意:您可以将此仓库设为私有。这样可以隐藏您的源文件,使其对公众不可见,并允许您将某些文件设为私有。但是,README.md 文件将是您个人资料上唯一存在的文件。如果您希望完全私有化 .profile 仓库,则需删除或重命名 README.md 文件。
|
||||
|
||||
用户示例 `.profile/README.md`:
|
||||
|
||||

|
||||
|
||||
@@ -97,7 +97,7 @@ func (r *ActionRunner) StatusName() string {
|
||||
}
|
||||
|
||||
func (r *ActionRunner) StatusLocaleName(lang translation.Locale) string {
|
||||
return lang.Tr("actions.runners.status." + r.StatusName())
|
||||
return lang.TrString("actions.runners.status." + r.StatusName())
|
||||
}
|
||||
|
||||
func (r *ActionRunner) IsOnline() bool {
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s Status) String() string {
|
||||
|
||||
// LocaleString returns the locale string name of the Status
|
||||
func (s Status) LocaleString(lang translation.Locale) string {
|
||||
return lang.Tr("actions.status." + s.String())
|
||||
return lang.TrString("actions.status." + s.String())
|
||||
}
|
||||
|
||||
// IsDone returns whether the Status is final
|
||||
|
||||
@@ -63,7 +63,6 @@ func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature st
|
||||
}
|
||||
if signer == nil {
|
||||
signer, err = hashAndVerifyWithSubKeys(sig, token+"\n", key)
|
||||
|
||||
if err != nil {
|
||||
return "", ErrGPGInvalidTokenSignature{
|
||||
ID: key.KeyID,
|
||||
|
||||
@@ -54,6 +54,11 @@ func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAuthTokensByUserID(ctx context.Context, uid int64) error {
|
||||
_, err := db.GetEngine(ctx).Where(builder.Eq{"user_id": uid}).Delete(&AuthToken{})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteExpiredAuthTokens(ctx context.Context) error {
|
||||
_, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{})
|
||||
return err
|
||||
|
||||
@@ -493,6 +493,23 @@ func (err ErrMergeUnrelatedHistories) Error() string {
|
||||
return fmt.Sprintf("Merge UnrelatedHistories Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// ErrMergeDivergingFastForwardOnly represents an error if a fast-forward-only merge fails because the branches diverge
|
||||
type ErrMergeDivergingFastForwardOnly struct {
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrMergeDivergingFastForwardOnly checks if an error is a ErrMergeDivergingFastForwardOnly.
|
||||
func IsErrMergeDivergingFastForwardOnly(err error) bool {
|
||||
_, ok := err.(ErrMergeDivergingFastForwardOnly)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrMergeDivergingFastForwardOnly) Error() string {
|
||||
return fmt.Sprintf("Merge DivergingFastForwardOnly Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// ErrRebaseConflicts represents an error if rebase fails with a conflict
|
||||
type ErrRebaseConflicts struct {
|
||||
Style repo_model.MergeStyle
|
||||
|
||||
@@ -285,3 +285,11 @@
|
||||
lower_email: abcde@gitea.com
|
||||
is_activated: true
|
||||
is_primary: false
|
||||
|
||||
-
|
||||
id: 37
|
||||
uid: 37
|
||||
email: user37@example.com
|
||||
lower_email: user37@example.com
|
||||
is_activated: true
|
||||
is_primary: true
|
||||
|
||||
@@ -1095,7 +1095,7 @@
|
||||
allow_git_hook: false
|
||||
allow_import_local: false
|
||||
allow_create_organization: true
|
||||
prohibit_login: true
|
||||
prohibit_login: false
|
||||
avatar: avatar29
|
||||
avatar_email: user30@example.com
|
||||
use_custom_avatar: false
|
||||
@@ -1332,3 +1332,40 @@
|
||||
repo_admin_change_team_access: false
|
||||
theme: ""
|
||||
keep_activity_private: false
|
||||
|
||||
-
|
||||
id: 37
|
||||
lower_name: user37
|
||||
name: user37
|
||||
full_name: User 37
|
||||
email: user37@example.com
|
||||
keep_email_private: false
|
||||
email_notifications_preference: enabled
|
||||
passwd: ZogKvWdyEx:password
|
||||
passwd_hash_algo: dummy
|
||||
must_change_password: false
|
||||
login_source: 0
|
||||
login_name: user37
|
||||
type: 0
|
||||
salt: ZogKvWdyEx
|
||||
max_repo_creation: -1
|
||||
is_active: true
|
||||
is_admin: false
|
||||
is_restricted: false
|
||||
allow_git_hook: false
|
||||
allow_import_local: false
|
||||
allow_create_organization: true
|
||||
prohibit_login: true
|
||||
avatar: avatar29
|
||||
avatar_email: user37@example.com
|
||||
use_custom_avatar: false
|
||||
num_followers: 0
|
||||
num_following: 0
|
||||
num_stars: 0
|
||||
num_repos: 0
|
||||
num_teams: 0
|
||||
num_members: 0
|
||||
visibility: 0
|
||||
repo_admin_change_team_access: false
|
||||
theme: ""
|
||||
keep_activity_private: false
|
||||
|
||||
@@ -194,7 +194,7 @@ func (status *CommitStatus) APIURL(ctx context.Context) string {
|
||||
|
||||
// LocaleString returns the locale string name of the Status
|
||||
func (status *CommitStatus) LocaleString(lang translation.Locale) string {
|
||||
return lang.Tr("repo.commitstatus." + status.State.String())
|
||||
return lang.TrString("repo.commitstatus." + status.State.String())
|
||||
}
|
||||
|
||||
// CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc
|
||||
|
||||
@@ -210,12 +210,12 @@ const (
|
||||
|
||||
// LocaleString returns the locale string name of the role
|
||||
func (r RoleInRepo) LocaleString(lang translation.Locale) string {
|
||||
return lang.Tr("repo.issues.role." + string(r))
|
||||
return lang.TrString("repo.issues.role." + string(r))
|
||||
}
|
||||
|
||||
// LocaleHelper returns the locale tooltip of the role
|
||||
func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
|
||||
return lang.Tr("repo.issues.role." + string(r) + "_helper")
|
||||
return lang.TrString("repo.issues.role." + string(r) + "_helper")
|
||||
}
|
||||
|
||||
// Comment represents a comment in commit and issue page.
|
||||
@@ -695,8 +695,15 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository
|
||||
}
|
||||
|
||||
func (c *Comment) loadReview(ctx context.Context) (err error) {
|
||||
if c.ReviewID == 0 {
|
||||
return nil
|
||||
}
|
||||
if c.Review == nil {
|
||||
if c.Review, err = GetReviewByID(ctx, c.ReviewID); err != nil {
|
||||
// review request which has been replaced by actual reviews doesn't exist in database anymore, so ignorem them.
|
||||
if c.Type == CommentTypeReviewRequest {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@ func (comments CommentList) loadAssignees(ctx context.Context) error {
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Assignee = assignees[comment.AssigneeID]
|
||||
if comment.Assignee == nil {
|
||||
comment.AssigneeID = user_model.GhostUserID
|
||||
comment.Assignee = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -430,7 +434,8 @@ func (comments CommentList) loadReviews(ctx context.Context) error {
|
||||
for _, comment := range comments {
|
||||
comment.Review = reviews[comment.ReviewID]
|
||||
if comment.Review == nil {
|
||||
if comment.ReviewID > 0 {
|
||||
// review request which has been replaced by actual reviews doesn't exist in database anymore, so don't log errors for them.
|
||||
if comment.ReviewID > 0 && comment.Type != CommentTypeReviewRequest {
|
||||
log.Error("comment with review id [%d] but has no review record", comment.ReviewID)
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -161,7 +161,11 @@ func FetchIssueContentHistoryList(dbCtx context.Context, issueID, commentID int6
|
||||
}
|
||||
|
||||
for _, item := range res {
|
||||
item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
|
||||
if item.UserID > 0 {
|
||||
item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
|
||||
} else {
|
||||
item.UserAvatarLink = avatars.DefaultAvatarLink()
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ func neuterCrossReferences(ctx context.Context, issueID, commentID int64) error
|
||||
for i, c := range active {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
return neuterCrossReferencesIds(ctx, ids)
|
||||
return neuterCrossReferencesIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func neuterCrossReferencesIds(ctx context.Context, ids []int64) error {
|
||||
func neuterCrossReferencesIDs(ctx context.Context, ids []int64) error {
|
||||
_, err := db.GetEngine(ctx).In("id", ids).Cols("`ref_action`").Update(&Comment{RefAction: references.XRefActionNeutered})
|
||||
return err
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err = neuterCrossReferencesIds(stdCtx, ids); err != nil {
|
||||
if err = neuterCrossReferencesIDs(stdCtx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,14 @@ func (r *Review) LoadReviewer(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
r.Reviewer, err = user_model.GetPossibleUserByID(ctx, r.ReviewerID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return fmt.Errorf("GetPossibleUserByID [%d]: %w", r.ReviewerID, err)
|
||||
}
|
||||
r.ReviewerID = user_model.GhostUserID
|
||||
r.Reviewer = user_model.NewGhostUser()
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -621,6 +629,9 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// func caller use the created comment to retrieve created review too.
|
||||
comment.Review = review
|
||||
|
||||
return comment, committer.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ type ReviewList []*Review
|
||||
|
||||
// LoadReviewers loads reviewers
|
||||
func (reviews ReviewList) LoadReviewers(ctx context.Context) error {
|
||||
reviewerIds := make([]int64, len(reviews))
|
||||
reviewerIDs := make([]int64, len(reviews))
|
||||
for i := 0; i < len(reviews); i++ {
|
||||
reviewerIds[i] = reviews[i].ReviewerID
|
||||
reviewerIDs[i] = reviews[i].ReviewerID
|
||||
}
|
||||
reviewers, err := user_model.GetPossibleUserByIDs(ctx, reviewerIds)
|
||||
reviewers, err := user_model.GetPossibleUserByIDs(ctx, reviewerIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -38,12 +38,12 @@ func (reviews ReviewList) LoadReviewers(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (reviews ReviewList) LoadIssues(ctx context.Context) error {
|
||||
issueIds := container.Set[int64]{}
|
||||
issueIDs := container.Set[int64]{}
|
||||
for i := 0; i < len(reviews); i++ {
|
||||
issueIds.Add(reviews[i].IssueID)
|
||||
issueIDs.Add(reviews[i].IssueID)
|
||||
}
|
||||
|
||||
issues, err := GetIssuesByIDs(ctx, issueIds.Values())
|
||||
issues, err := GetIssuesByIDs(ctx, issueIDs.Values())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -594,9 +594,7 @@ func GetOrgByID(ctx context.Context, id int64) (*Organization, error) {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, user_model.ErrUserNotExist{
|
||||
UID: id,
|
||||
Name: "",
|
||||
KeyID: 0,
|
||||
UID: id,
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
MergeStyleRebaseMerge MergeStyle = "rebase-merge"
|
||||
// MergeStyleSquash squash commits into single commit before merging
|
||||
MergeStyleSquash MergeStyle = "squash"
|
||||
// MergeStyleFastForwardOnly fast-forward merge if possible, otherwise fail
|
||||
MergeStyleFastForwardOnly MergeStyle = "fast-forward-only"
|
||||
// MergeStyleManuallyMerged pr has been merged manually, just mark it as merged directly
|
||||
MergeStyleManuallyMerged MergeStyle = "manually-merged"
|
||||
// MergeStyleRebaseUpdate not a merge style, used to update pull head by rebase
|
||||
|
||||
@@ -122,6 +122,7 @@ type PullRequestsConfig struct {
|
||||
AllowRebase bool
|
||||
AllowRebaseMerge bool
|
||||
AllowSquash bool
|
||||
AllowFastForwardOnly bool
|
||||
AllowManualMerge bool
|
||||
AutodetectManualMerge bool
|
||||
AllowRebaseUpdate bool
|
||||
@@ -148,6 +149,7 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
|
||||
mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
|
||||
mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
|
||||
mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
|
||||
mergeStyle == MergeStyleFastForwardOnly && cfg.AllowFastForwardOnly ||
|
||||
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package repo
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -135,55 +134,6 @@ func CheckCreateRepository(ctx context.Context, doer, u *user_model.User, name s
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeRepositoryName changes all corresponding setting from old repository name to new one.
|
||||
func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *Repository, newRepoName string) (err error) {
|
||||
oldRepoName := repo.Name
|
||||
newRepoName = strings.ToLower(newRepoName)
|
||||
if err = IsUsableRepoName(newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return ErrRepoAlreadyExist{repo.Owner.Name, newRepoName}
|
||||
}
|
||||
|
||||
newRepoPath := 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, 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 := NewRedirect(ctx, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// UpdateRepoSize updates the repository size, calculating it using getDirectorySize
|
||||
func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{
|
||||
|
||||
+1
-246
@@ -6,17 +6,13 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
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/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// RepoTransfer is used to manage repository transfers
|
||||
@@ -115,32 +111,11 @@ func GetPendingRepositoryTransfer(ctx context.Context, repo *repo_model.Reposito
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func deleteRepositoryTransfer(ctx context.Context, repoID int64) error {
|
||||
func DeleteRepositoryTransfer(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(&RepoTransfer{})
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 := deleteRepositoryTransfer(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// TestRepositoryReadyForTransfer make sure repo is ready to transfer
|
||||
func TestRepositoryReadyForTransfer(status repo_model.RepositoryStatus) error {
|
||||
switch status {
|
||||
@@ -197,223 +172,3 @@ func CreatePendingRepositoryTransfer(ctx context.Context, doer, newOwner *user_m
|
||||
return db.Insert(ctx, transfer)
|
||||
})
|
||||
}
|
||||
|
||||
// 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.ID, db.ListOptions{})
|
||||
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 := 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.ID, repo.ID, true); err != nil {
|
||||
return fmt.Errorf("watchRepo: %w", err)
|
||||
}
|
||||
|
||||
// Remove watch for organization.
|
||||
if oldOwner.IsOrganization() {
|
||||
if err := repo_model.WatchRepo(ctx, oldOwner.ID, repo.ID, 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 := 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()
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
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 := GetPendingRepositoryTransfer(db.DefaultContext, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, transfer)
|
||||
|
||||
// Cancel transfer
|
||||
assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo))
|
||||
|
||||
transfer, err = GetPendingRepositoryTransfer(db.DefaultContext, repo)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, transfer)
|
||||
assert.True(t, IsErrNoPendingTransfer(err))
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.NoError(t, CreatePendingRepositoryTransfer(db.DefaultContext, doer, user2, repo.ID, nil))
|
||||
|
||||
transfer, err = 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 = CreatePendingRepositoryTransfer(db.DefaultContext, doer, org6, repo.ID, nil)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrRepoTransferInProgress(err))
|
||||
|
||||
// Unknown user
|
||||
err = 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))
|
||||
}
|
||||
@@ -17,13 +17,13 @@ const (
|
||||
func (o OwnerType) LocaleString(locale translation.Locale) string {
|
||||
switch o {
|
||||
case OwnerTypeSystemGlobal:
|
||||
return locale.Tr("concept_system_global")
|
||||
return locale.TrString("concept_system_global")
|
||||
case OwnerTypeIndividual:
|
||||
return locale.Tr("concept_user_individual")
|
||||
return locale.TrString("concept_user_individual")
|
||||
case OwnerTypeRepository:
|
||||
return locale.Tr("concept_code_repository")
|
||||
return locale.TrString("concept_code_repository")
|
||||
case OwnerTypeOrganization:
|
||||
return locale.Tr("concept_user_organization")
|
||||
return locale.TrString("concept_user_organization")
|
||||
}
|
||||
return locale.Tr("unknown")
|
||||
return locale.TrString("unknown")
|
||||
}
|
||||
|
||||
@@ -44,12 +44,12 @@ func fatalTestError(fmtStr string, args ...any) {
|
||||
}
|
||||
|
||||
// InitSettings initializes config provider and load common settings for tests
|
||||
func InitSettings(extraConfigs ...string) {
|
||||
func InitSettings() {
|
||||
if setting.CustomConf == "" {
|
||||
setting.CustomConf = filepath.Join(setting.CustomPath, "conf/app-unittest-tmp.ini")
|
||||
_ = os.Remove(setting.CustomConf)
|
||||
}
|
||||
setting.InitCfgProvider(setting.CustomConf, strings.Join(extraConfigs, "\n"))
|
||||
setting.InitCfgProvider(setting.CustomConf)
|
||||
setting.LoadCommonSettings()
|
||||
|
||||
if err := setting.PrepareAppDataPath(); err != nil {
|
||||
|
||||
@@ -142,12 +142,24 @@ func (email *EmailAddress) BeforeInsert() {
|
||||
}
|
||||
}
|
||||
|
||||
func InsertEmailAddress(ctx context.Context, email *EmailAddress) (*EmailAddress, error) {
|
||||
if err := db.Insert(ctx, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func UpdateEmailAddress(ctx context.Context, email *EmailAddress) error {
|
||||
_, err := db.GetEngine(ctx).ID(email.ID).AllCols().Update(email)
|
||||
return err
|
||||
}
|
||||
|
||||
var emailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
|
||||
// ValidateEmail check if email is a allowed address
|
||||
func ValidateEmail(email string) error {
|
||||
if len(email) == 0 {
|
||||
return nil
|
||||
return ErrEmailInvalid{email}
|
||||
}
|
||||
|
||||
if !emailRegexp.MatchString(email) {
|
||||
@@ -177,6 +189,36 @@ func ValidateEmail(email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetEmailAddressByEmail(ctx context.Context, email string) (*EmailAddress, error) {
|
||||
ea := &EmailAddress{}
|
||||
if has, err := db.GetEngine(ctx).Where("lower_email=?", strings.ToLower(email)).Get(ea); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrEmailAddressNotExist{email}
|
||||
}
|
||||
return ea, nil
|
||||
}
|
||||
|
||||
func GetEmailAddressOfUser(ctx context.Context, email string, uid int64) (*EmailAddress, error) {
|
||||
ea := &EmailAddress{}
|
||||
if has, err := db.GetEngine(ctx).Where("lower_email=? AND uid=?", strings.ToLower(email), uid).Get(ea); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrEmailAddressNotExist{email}
|
||||
}
|
||||
return ea, nil
|
||||
}
|
||||
|
||||
func GetPrimaryEmailAddressOfUser(ctx context.Context, uid int64) (*EmailAddress, error) {
|
||||
ea := &EmailAddress{}
|
||||
if has, err := db.GetEngine(ctx).Where("uid=? AND is_primary=?", uid, true).Get(ea); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrEmailAddressNotExist{}
|
||||
}
|
||||
return ea, nil
|
||||
}
|
||||
|
||||
// GetEmailAddresses returns all email addresses belongs to given user.
|
||||
func GetEmailAddresses(ctx context.Context, uid int64) ([]*EmailAddress, error) {
|
||||
emails := make([]*EmailAddress, 0, 5)
|
||||
@@ -235,91 +277,6 @@ func IsEmailUsed(ctx context.Context, email string) (bool, error) {
|
||||
return db.GetEngine(ctx).Where("lower_email=?", strings.ToLower(email)).Get(&EmailAddress{})
|
||||
}
|
||||
|
||||
// AddEmailAddress adds an email address to given user.
|
||||
func AddEmailAddress(ctx context.Context, email *EmailAddress) error {
|
||||
email.Email = strings.TrimSpace(email.Email)
|
||||
used, err := IsEmailUsed(ctx, email.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if used {
|
||||
return ErrEmailAlreadyUsed{email.Email}
|
||||
}
|
||||
|
||||
if err = ValidateEmail(email.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Insert(ctx, email)
|
||||
}
|
||||
|
||||
// AddEmailAddresses adds an email address to given user.
|
||||
func AddEmailAddresses(ctx context.Context, emails []*EmailAddress) error {
|
||||
if len(emails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any of them has been used
|
||||
for i := range emails {
|
||||
emails[i].Email = strings.TrimSpace(emails[i].Email)
|
||||
used, err := IsEmailUsed(ctx, emails[i].Email)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if used {
|
||||
return ErrEmailAlreadyUsed{emails[i].Email}
|
||||
}
|
||||
if err = ValidateEmail(emails[i].Email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, emails); err != nil {
|
||||
return fmt.Errorf("Insert: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEmailAddress deletes an email address of given user.
|
||||
func DeleteEmailAddress(ctx context.Context, email *EmailAddress) (err error) {
|
||||
if email.IsPrimary {
|
||||
return ErrPrimaryEmailCannotDelete{Email: email.Email}
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
// ask to check UID
|
||||
address := EmailAddress{
|
||||
UID: email.UID,
|
||||
}
|
||||
if email.ID > 0 {
|
||||
deleted, err = db.GetEngine(ctx).ID(email.ID).Delete(&address)
|
||||
} else {
|
||||
if email.Email != "" && email.LowerEmail == "" {
|
||||
email.LowerEmail = strings.ToLower(email.Email)
|
||||
}
|
||||
deleted, err = db.GetEngine(ctx).
|
||||
Where("lower_email=?", email.LowerEmail).
|
||||
Delete(&address)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deleted != 1 {
|
||||
return ErrEmailAddressNotExist{Email: email.Email}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEmailAddresses deletes multiple email addresses
|
||||
func DeleteEmailAddresses(ctx context.Context, emails []*EmailAddress) (err error) {
|
||||
for i := range emails {
|
||||
if err = DeleteEmailAddress(ctx, emails[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInactiveEmailAddresses deletes inactive email addresses
|
||||
func DeleteInactiveEmailAddresses(ctx context.Context) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
@@ -375,9 +332,7 @@ func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
|
||||
return err
|
||||
} else if !has {
|
||||
return ErrUserNotExist{
|
||||
UID: email.UID,
|
||||
Name: "",
|
||||
KeyID: 0,
|
||||
UID: email.UID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,96 +42,6 @@ func TestIsEmailUsed(t *testing.T) {
|
||||
assert.False(t, isExist)
|
||||
}
|
||||
|
||||
func TestAddEmailAddress(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
assert.NoError(t, user_model.AddEmailAddress(db.DefaultContext, &user_model.EmailAddress{
|
||||
Email: "user1234567890@example.com",
|
||||
LowerEmail: "user1234567890@example.com",
|
||||
IsPrimary: true,
|
||||
IsActivated: true,
|
||||
}))
|
||||
|
||||
// ErrEmailAlreadyUsed
|
||||
err := user_model.AddEmailAddress(db.DefaultContext, &user_model.EmailAddress{
|
||||
Email: "user1234567890@example.com",
|
||||
LowerEmail: "user1234567890@example.com",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestAddEmailAddresses(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// insert multiple email address
|
||||
emails := make([]*user_model.EmailAddress, 2)
|
||||
emails[0] = &user_model.EmailAddress{
|
||||
Email: "user1234@example.com",
|
||||
LowerEmail: "user1234@example.com",
|
||||
IsActivated: true,
|
||||
}
|
||||
emails[1] = &user_model.EmailAddress{
|
||||
Email: "user5678@example.com",
|
||||
LowerEmail: "user5678@example.com",
|
||||
IsActivated: true,
|
||||
}
|
||||
assert.NoError(t, user_model.AddEmailAddresses(db.DefaultContext, emails))
|
||||
|
||||
// ErrEmailAlreadyUsed
|
||||
err := user_model.AddEmailAddresses(db.DefaultContext, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestDeleteEmailAddress(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
assert.NoError(t, user_model.DeleteEmailAddress(db.DefaultContext, &user_model.EmailAddress{
|
||||
UID: int64(1),
|
||||
ID: int64(33),
|
||||
Email: "user1-2@example.com",
|
||||
LowerEmail: "user1-2@example.com",
|
||||
}))
|
||||
|
||||
assert.NoError(t, user_model.DeleteEmailAddress(db.DefaultContext, &user_model.EmailAddress{
|
||||
UID: int64(1),
|
||||
Email: "user1-3@example.com",
|
||||
LowerEmail: "user1-3@example.com",
|
||||
}))
|
||||
|
||||
// Email address does not exist
|
||||
err := user_model.DeleteEmailAddress(db.DefaultContext, &user_model.EmailAddress{
|
||||
UID: int64(1),
|
||||
Email: "user1234567890@example.com",
|
||||
LowerEmail: "user1234567890@example.com",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteEmailAddresses(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// delete multiple email address
|
||||
emails := make([]*user_model.EmailAddress, 2)
|
||||
emails[0] = &user_model.EmailAddress{
|
||||
UID: int64(2),
|
||||
ID: int64(3),
|
||||
Email: "user2@example.com",
|
||||
LowerEmail: "user2@example.com",
|
||||
}
|
||||
emails[1] = &user_model.EmailAddress{
|
||||
UID: int64(2),
|
||||
Email: "user2-2@example.com",
|
||||
LowerEmail: "user2-2@example.com",
|
||||
}
|
||||
assert.NoError(t, user_model.DeleteEmailAddresses(db.DefaultContext, emails))
|
||||
|
||||
// ErrEmailAlreadyUsed
|
||||
err := user_model.DeleteEmailAddresses(db.DefaultContext, emails)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMakeEmailPrimary(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
+3
-19
@@ -31,9 +31,8 @@ func (err ErrUserAlreadyExist) Unwrap() error {
|
||||
|
||||
// ErrUserNotExist represents a "UserNotExist" kind of error.
|
||||
type ErrUserNotExist struct {
|
||||
UID int64
|
||||
Name string
|
||||
KeyID int64
|
||||
UID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrUserNotExist checks if an error is a ErrUserNotExist.
|
||||
@@ -43,7 +42,7 @@ func IsErrUserNotExist(err error) bool {
|
||||
}
|
||||
|
||||
func (err ErrUserNotExist) Error() string {
|
||||
return fmt.Sprintf("user does not exist [uid: %d, name: %s, keyid: %d]", err.UID, err.Name, err.KeyID)
|
||||
return fmt.Sprintf("user does not exist [uid: %d, name: %s]", err.UID, err.Name)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrNotExist error
|
||||
@@ -108,18 +107,3 @@ func IsErrUserIsNotLocal(err error) bool {
|
||||
_, ok := err.(ErrUserIsNotLocal)
|
||||
return ok
|
||||
}
|
||||
|
||||
type ErrUsernameNotChanged struct {
|
||||
UID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func (err ErrUsernameNotChanged) Error() string {
|
||||
return fmt.Sprintf("username hasn't been changed[uid: %d, name: %s]", err.UID, err.Name)
|
||||
}
|
||||
|
||||
// IsErrUsernameNotChanged
|
||||
func IsErrUsernameNotChanged(err error) bool {
|
||||
_, ok := err.(ErrUsernameNotChanged)
|
||||
return ok
|
||||
}
|
||||
|
||||
+26
-153
@@ -196,18 +196,6 @@ func (u *User) SetLastLogin() {
|
||||
u.LastLoginUnix = timeutil.TimeStampNow()
|
||||
}
|
||||
|
||||
// UpdateUserDiffViewStyle updates the users diff view style
|
||||
func UpdateUserDiffViewStyle(ctx context.Context, u *User, style string) error {
|
||||
u.DiffViewStyle = style
|
||||
return UpdateUserCols(ctx, u, "diff_view_style")
|
||||
}
|
||||
|
||||
// UpdateUserTheme updates a users' theme irrespective of the site wide theme
|
||||
func UpdateUserTheme(ctx context.Context, u *User, themeName string) error {
|
||||
u.Theme = themeName
|
||||
return UpdateUserCols(ctx, u, "theme")
|
||||
}
|
||||
|
||||
// GetPlaceholderEmail returns an noreply email
|
||||
func (u *User) GetPlaceholderEmail() string {
|
||||
return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress)
|
||||
@@ -378,13 +366,6 @@ func (u *User) NewGitSig() *git.Signature {
|
||||
// SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO
|
||||
// change passwd, salt and passwd_hash_algo fields
|
||||
func (u *User) SetPassword(passwd string) (err error) {
|
||||
if len(passwd) == 0 {
|
||||
u.Passwd = ""
|
||||
u.Salt = ""
|
||||
u.PasswdHashAlgo = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
if u.Salt, err = GetUserSalt(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -443,6 +424,17 @@ func (u *User) GetDisplayName() string {
|
||||
return u.Name
|
||||
}
|
||||
|
||||
// GetCompleteName returns the the full name and username in the form of
|
||||
// "Full Name (username)" if full name is not empty, otherwise it returns
|
||||
// "username".
|
||||
func (u *User) GetCompleteName() string {
|
||||
trimmedFullName := strings.TrimSpace(u.FullName)
|
||||
if len(trimmedFullName) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", trimmedFullName, u.Name)
|
||||
}
|
||||
return u.Name
|
||||
}
|
||||
|
||||
func gitSafeName(name string) string {
|
||||
return strings.TrimSpace(strings.NewReplacer("\n", "", "<", "", ">", "").Replace(name))
|
||||
}
|
||||
@@ -477,21 +469,6 @@ func (u *User) IsMailable() bool {
|
||||
return u.IsActive
|
||||
}
|
||||
|
||||
// EmailNotifications returns the User's email notification preference
|
||||
func (u *User) EmailNotifications() string {
|
||||
return u.EmailNotificationsPreference
|
||||
}
|
||||
|
||||
// SetEmailNotifications sets the user's email notification preference
|
||||
func SetEmailNotifications(ctx context.Context, u *User, set string) error {
|
||||
u.EmailNotificationsPreference = set
|
||||
if err := UpdateUserCols(ctx, u, "email_notifications_preference"); err != nil {
|
||||
log.Error("SetEmailNotifications: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserExist checks if given user name exist,
|
||||
// the user name should be noncased unique.
|
||||
// If uid is presented, then check will rule out that one,
|
||||
@@ -694,8 +671,13 @@ func CreateUser(ctx context.Context, u *User, overwriteDefault ...*CreateUserOve
|
||||
if u.Rands, err = GetUserSalt(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = u.SetPassword(u.Passwd); err != nil {
|
||||
return err
|
||||
if u.Passwd != "" {
|
||||
if err = u.SetPassword(u.Passwd); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
u.Salt = ""
|
||||
u.PasswdHashAlgo = ""
|
||||
}
|
||||
|
||||
// save changes to database
|
||||
@@ -806,24 +788,6 @@ func VerifyUserActiveCode(ctx context.Context, code string) (user *User) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDupEmail checks whether there are the same email with the user
|
||||
func checkDupEmail(ctx context.Context, u *User) error {
|
||||
u.Email = strings.ToLower(u.Email)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("id!=?", u.ID).
|
||||
And("type=?", u.Type).
|
||||
And("email=?", u.Email).
|
||||
Get(new(User))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
return ErrEmailAlreadyUsed{
|
||||
Email: u.Email,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUser check if user is valid to insert / update into database
|
||||
func ValidateUser(u *User, cols ...string) error {
|
||||
if len(cols) == 0 || util.SliceContainsString(cols, "visibility", true) {
|
||||
@@ -832,81 +796,9 @@ func ValidateUser(u *User, cols ...string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 || util.SliceContainsString(cols, "email", true) {
|
||||
u.Email = strings.ToLower(u.Email)
|
||||
if err := ValidateEmail(u.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser updates user's information.
|
||||
func UpdateUser(ctx context.Context, u *User, changePrimaryEmail bool, cols ...string) error {
|
||||
err := ValidateUser(u, cols...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
if changePrimaryEmail {
|
||||
var emailAddress EmailAddress
|
||||
has, err := e.Where("lower_email=?", strings.ToLower(u.Email)).Get(&emailAddress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has && emailAddress.UID != u.ID {
|
||||
return ErrEmailAlreadyUsed{
|
||||
Email: u.Email,
|
||||
}
|
||||
}
|
||||
// 1. Update old primary email
|
||||
if _, err = e.Where("uid=? AND is_primary=?", u.ID, true).Cols("is_primary").Update(&EmailAddress{
|
||||
IsPrimary: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !has {
|
||||
emailAddress.Email = u.Email
|
||||
emailAddress.UID = u.ID
|
||||
emailAddress.IsActivated = true
|
||||
emailAddress.IsPrimary = true
|
||||
if _, err := e.Insert(&emailAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := e.ID(emailAddress.ID).Cols("is_primary").Update(&EmailAddress{
|
||||
IsPrimary: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !u.IsOrganization() { // check if primary email in email_address table
|
||||
primaryEmailExist, err := e.Where("uid=? AND is_primary=?", u.ID, true).Exist(&EmailAddress{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !primaryEmailExist {
|
||||
if _, err := e.Insert(&EmailAddress{
|
||||
Email: u.Email,
|
||||
UID: u.ID,
|
||||
IsActivated: true,
|
||||
IsPrimary: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
_, err = e.ID(u.ID).AllCols().Update(u)
|
||||
} else {
|
||||
_, err = e.ID(u.ID).Cols(cols...).Update(u)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserCols update user according special columns
|
||||
func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
|
||||
if err := ValidateUser(u, cols...); err != nil {
|
||||
@@ -917,25 +809,6 @@ func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserSetting updates user's settings.
|
||||
func UpdateUserSetting(ctx context.Context, u *User) (err error) {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if !u.IsOrganization() {
|
||||
if err = checkDupEmail(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = UpdateUser(ctx, u, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// GetInactiveUsers gets all inactive users
|
||||
func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, error) {
|
||||
var cond builder.Cond = builder.Eq{"is_active": false}
|
||||
@@ -962,7 +835,7 @@ func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrUserNotExist{id, "", 0}
|
||||
return nil, ErrUserNotExist{UID: id}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -1012,14 +885,14 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||
// GetUserByNameCtx returns user by given name.
|
||||
func GetUserByName(ctx context.Context, name string) (*User, error) {
|
||||
if len(name) == 0 {
|
||||
return nil, ErrUserNotExist{0, name, 0}
|
||||
return nil, ErrUserNotExist{Name: name}
|
||||
}
|
||||
u := &User{LowerName: strings.ToLower(name), Type: UserTypeIndividual}
|
||||
has, err := db.GetEngine(ctx).Get(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrUserNotExist{0, name, 0}
|
||||
return nil, ErrUserNotExist{Name: name}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -1033,7 +906,7 @@ func GetUserEmailsByNames(ctx context.Context, names []string) []string {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if u.IsMailable() && u.EmailNotifications() != EmailNotificationsDisabled {
|
||||
if u.IsMailable() && u.EmailNotificationsPreference != EmailNotificationsDisabled {
|
||||
mails = append(mails, u.Email)
|
||||
}
|
||||
}
|
||||
@@ -1160,7 +1033,7 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) []
|
||||
// GetUserByEmail returns the user object by given e-mail if exists.
|
||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
if len(email) == 0 {
|
||||
return nil, ErrUserNotExist{0, email, 0}
|
||||
return nil, ErrUserNotExist{Name: email}
|
||||
}
|
||||
|
||||
email = strings.ToLower(email)
|
||||
@@ -1187,7 +1060,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrUserNotExist{0, email, 0}
|
||||
return nil, ErrUserNotExist{Name: email}
|
||||
}
|
||||
|
||||
// GetUser checks if a user already exists
|
||||
@@ -1198,7 +1071,7 @@ func GetUser(ctx context.Context, user *User) (bool, error) {
|
||||
// GetUserByOpenID returns the user object by given OpenID if exists.
|
||||
func GetUserByOpenID(ctx context.Context, uri string) (*User, error) {
|
||||
if len(uri) == 0 {
|
||||
return nil, ErrUserNotExist{0, uri, 0}
|
||||
return nil, ErrUserNotExist{Name: uri}
|
||||
}
|
||||
|
||||
uri, err := openid.Normalize(uri)
|
||||
@@ -1218,7 +1091,7 @@ func GetUserByOpenID(ctx context.Context, uri string) (*User, error) {
|
||||
return GetUserByID(ctx, oid.UID)
|
||||
}
|
||||
|
||||
return nil, ErrUserNotExist{0, uri, 0}
|
||||
return nil, ErrUserNotExist{Name: uri}
|
||||
}
|
||||
|
||||
// GetAdminUser returns the first administrator
|
||||
|
||||
@@ -101,13 +101,13 @@ func TestSearchUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}},
|
||||
[]int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34})
|
||||
[]int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37})
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsActive: util.OptionalBoolFalse},
|
||||
[]int64{9})
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
|
||||
[]int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34})
|
||||
[]int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37})
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
|
||||
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})
|
||||
@@ -123,7 +123,7 @@ func TestSearchUsers(t *testing.T) {
|
||||
[]int64{29})
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: util.OptionalBoolTrue},
|
||||
[]int64{30})
|
||||
[]int64{37})
|
||||
|
||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: util.OptionalBoolTrue},
|
||||
[]int64{24})
|
||||
@@ -147,20 +147,7 @@ func TestEmailNotificationPreferences(t *testing.T) {
|
||||
{user_model.EmailNotificationsOnMention, 9},
|
||||
} {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.userID})
|
||||
assert.Equal(t, test.expected, user.EmailNotifications())
|
||||
|
||||
// Try all possible settings
|
||||
assert.NoError(t, user_model.SetEmailNotifications(db.DefaultContext, user, user_model.EmailNotificationsEnabled))
|
||||
assert.Equal(t, user_model.EmailNotificationsEnabled, user.EmailNotifications())
|
||||
|
||||
assert.NoError(t, user_model.SetEmailNotifications(db.DefaultContext, user, user_model.EmailNotificationsOnMention))
|
||||
assert.Equal(t, user_model.EmailNotificationsOnMention, user.EmailNotifications())
|
||||
|
||||
assert.NoError(t, user_model.SetEmailNotifications(db.DefaultContext, user, user_model.EmailNotificationsDisabled))
|
||||
assert.Equal(t, user_model.EmailNotificationsDisabled, user.EmailNotifications())
|
||||
|
||||
assert.NoError(t, user_model.SetEmailNotifications(db.DefaultContext, user, user_model.EmailNotificationsAndYourOwn))
|
||||
assert.Equal(t, user_model.EmailNotificationsAndYourOwn, user.EmailNotifications())
|
||||
assert.Equal(t, test.expected, user.EmailNotificationsPreference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,42 +330,6 @@ func TestGetMaileableUsersByIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
user.KeepActivityPrivate = true
|
||||
assert.NoError(t, user_model.UpdateUser(db.DefaultContext, user, false))
|
||||
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
assert.True(t, user.KeepActivityPrivate)
|
||||
|
||||
setting.Service.AllowedUserVisibilityModesSlice = []bool{true, false, false}
|
||||
user.KeepActivityPrivate = false
|
||||
user.Visibility = structs.VisibleTypePrivate
|
||||
assert.Error(t, user_model.UpdateUser(db.DefaultContext, user, false))
|
||||
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
assert.True(t, user.KeepActivityPrivate)
|
||||
|
||||
newEmail := "new_" + user.Email
|
||||
user.Email = newEmail
|
||||
assert.NoError(t, user_model.UpdateUser(db.DefaultContext, user, true))
|
||||
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
assert.Equal(t, newEmail, user.Email)
|
||||
|
||||
user.Email = "no mail@mail.org"
|
||||
assert.Error(t, user_model.UpdateUser(db.DefaultContext, user, true))
|
||||
}
|
||||
|
||||
func TestUpdateUserEmailAlreadyUsed(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
|
||||
user2.Email = org3.Email
|
||||
err := user_model.UpdateUser(db.DefaultContext, user2, true)
|
||||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestNewUserRedirect(t *testing.T) {
|
||||
// redirect to a completely new name
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
@@ -534,14 +485,12 @@ func Test_ValidateUser(t *testing.T) {
|
||||
}()
|
||||
setting.Service.AllowedUserVisibilityModesSlice = []bool{true, false, true}
|
||||
kases := map[*user_model.User]bool{
|
||||
{ID: 1, Visibility: structs.VisibleTypePublic}: true,
|
||||
{ID: 2, Visibility: structs.VisibleTypeLimited}: false,
|
||||
{ID: 2, Visibility: structs.VisibleTypeLimited, Email: "invalid"}: false,
|
||||
{ID: 2, Visibility: structs.VisibleTypePrivate, Email: "valid@valid.com"}: true,
|
||||
{ID: 1, Visibility: structs.VisibleTypePublic}: true,
|
||||
{ID: 2, Visibility: structs.VisibleTypeLimited}: false,
|
||||
{ID: 2, Visibility: structs.VisibleTypePrivate}: true,
|
||||
}
|
||||
for kase, expected := range kases {
|
||||
err := user_model.ValidateUser(kase)
|
||||
assert.EqualValues(t, expected, err == nil, fmt.Sprintf("case: %+v", kase))
|
||||
assert.EqualValues(t, expected, nil == user_model.ValidateUser(kase), fmt.Sprintf("case: %+v", kase))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ package password
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
goContext "context"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"html/template"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,6 +17,11 @@ import (
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrComplexity = errors.New("password not complex enough")
|
||||
ErrMinLength = errors.New("password not long enough")
|
||||
)
|
||||
|
||||
// complexity contains information about a particular kind of password complexity
|
||||
type complexity struct {
|
||||
ValidChars string
|
||||
@@ -101,26 +108,29 @@ func Generate(n int) (string, error) {
|
||||
}
|
||||
buffer[j] = validChars[rnd.Int64()]
|
||||
}
|
||||
pwned, err := IsPwned(goContext.Background(), string(buffer))
|
||||
if err != nil {
|
||||
|
||||
if err := IsPwned(context.Background(), string(buffer)); err != nil {
|
||||
if errors.Is(err, ErrIsPwned) {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if IsComplexEnough(string(buffer)) && !pwned && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
|
||||
if IsComplexEnough(string(buffer)) && string(buffer[0]) != " " && string(buffer[n-1]) != " " {
|
||||
return string(buffer), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildComplexityError builds the error message when password complexity checks fail
|
||||
func BuildComplexityError(locale translation.Locale) string {
|
||||
func BuildComplexityError(locale translation.Locale) template.HTML {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString(locale.Tr("form.password_complexity"))
|
||||
buffer.WriteString(locale.TrString("form.password_complexity"))
|
||||
buffer.WriteString("<ul>")
|
||||
for _, c := range requiredList {
|
||||
buffer.WriteString("<li>")
|
||||
buffer.WriteString(locale.Tr(c.TrNameOne))
|
||||
buffer.WriteString(locale.TrString(c.TrNameOne))
|
||||
buffer.WriteString("</li>")
|
||||
}
|
||||
buffer.WriteString("</ul>")
|
||||
return buffer.String()
|
||||
return template.HTML(buffer.String())
|
||||
}
|
||||
|
||||
@@ -5,24 +5,48 @@ package password
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/modules/auth/password/pwn"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
var ErrIsPwned = errors.New("password has been pwned")
|
||||
|
||||
type ErrIsPwnedRequest struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func IsErrIsPwnedRequest(err error) bool {
|
||||
_, ok := err.(ErrIsPwnedRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIsPwnedRequest) Error() string {
|
||||
return fmt.Sprintf("using Have-I-Been-Pwned service failed: %v", err.err)
|
||||
}
|
||||
|
||||
func (err ErrIsPwnedRequest) Unwrap() error {
|
||||
return err.err
|
||||
}
|
||||
|
||||
// IsPwned checks whether a password has been pwned
|
||||
// NOTE: This func returns true if it encounters an error under the assumption that you ALWAYS want to check against
|
||||
// HIBP, so not getting a response should block a password until it can be verified.
|
||||
func IsPwned(ctx context.Context, password string) (bool, error) {
|
||||
// If a password has not been pwned, no error is returned.
|
||||
func IsPwned(ctx context.Context, password string) error {
|
||||
if !setting.PasswordCheckPwn {
|
||||
return false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
client := pwn.New(pwn.WithContext(ctx))
|
||||
count, err := client.CheckPassword(password, true)
|
||||
if err != nil {
|
||||
return true, err
|
||||
return ErrIsPwnedRequest{err}
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
if count > 0 {
|
||||
return ErrIsPwned
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*h
|
||||
// because artificial responses will be added to the response
|
||||
// For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/
|
||||
func (c *Client) CheckPassword(pw string, padding bool) (int, error) {
|
||||
if strings.TrimSpace(pw) == "" {
|
||||
if pw == "" {
|
||||
return -1, ErrEmptyPassword
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
package pwn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var client = New(WithHTTP(&http.Client{
|
||||
@@ -25,78 +26,44 @@ func TestMain(m *testing.M) {
|
||||
func TestPassword(t *testing.T) {
|
||||
// Check input error
|
||||
_, err := client.CheckPassword("", false)
|
||||
if err == nil {
|
||||
t.Log("blank input should return an error")
|
||||
t.Fail()
|
||||
}
|
||||
if !errors.Is(err, ErrEmptyPassword) {
|
||||
t.Log("blank input should return ErrEmptyPassword")
|
||||
t.Fail()
|
||||
}
|
||||
assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword")
|
||||
|
||||
// Should fail
|
||||
fail := "password1234"
|
||||
count, err := client.CheckPassword(fail, false)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
t.Fail()
|
||||
}
|
||||
if count == 0 {
|
||||
t.Logf("%s should fail as a password\n", fail)
|
||||
t.Fail()
|
||||
}
|
||||
assert.NotEmpty(t, count, "%s should fail as a password", fail)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should fail (with padding)
|
||||
failPad := "administrator"
|
||||
count, err = client.CheckPassword(failPad, true)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
t.Fail()
|
||||
}
|
||||
if count == 0 {
|
||||
t.Logf("%s should fail as a password\n", failPad)
|
||||
t.Fail()
|
||||
}
|
||||
assert.NotEmpty(t, count, "%s should fail as a password", failPad)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Checking for a "good" password isn't going to be perfect, but we can give it a good try
|
||||
// with hopefully minimal error. Try five times?
|
||||
var good bool
|
||||
var pw string
|
||||
for idx := 0; idx <= 5; idx++ {
|
||||
pw = testPassword()
|
||||
count, err = client.CheckPassword(pw, false)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
t.Fail()
|
||||
assert.Condition(t, func() bool {
|
||||
for i := 0; i <= 5; i++ {
|
||||
count, err = client.CheckPassword(testPassword(), false)
|
||||
assert.NoError(t, err)
|
||||
if count == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
good = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !good {
|
||||
t.Log("no generated passwords passed. there is a chance this is a fluke")
|
||||
t.Fail()
|
||||
}
|
||||
return false
|
||||
}, "no generated passwords passed. there is a chance this is a fluke")
|
||||
|
||||
// Again, but with padded responses
|
||||
good = false
|
||||
for idx := 0; idx <= 5; idx++ {
|
||||
pw = testPassword()
|
||||
count, err = client.CheckPassword(pw, true)
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
t.Fail()
|
||||
assert.Condition(t, func() bool {
|
||||
for i := 0; i <= 5; i++ {
|
||||
count, err = client.CheckPassword(testPassword(), true)
|
||||
assert.NoError(t, err)
|
||||
if count == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
good = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !good {
|
||||
t.Log("no generated passwords passed. there is a chance this is a fluke")
|
||||
t.Fail()
|
||||
}
|
||||
return false
|
||||
}, "no generated passwords passed. there is a chance this is a fluke")
|
||||
}
|
||||
|
||||
// Credit to https://golangbyexample.com/generate-random-password-golang/
|
||||
|
||||
@@ -173,7 +173,7 @@ func (e *escapeStreamer) ambiguousRune(r, c rune) error {
|
||||
Val: "ambiguous-code-point",
|
||||
}, html.Attribute{
|
||||
Key: "data-tooltip-content",
|
||||
Val: e.locale.Tr("repo.ambiguous_character", r, c),
|
||||
Val: e.locale.TrString("repo.ambiguous_character", r, c),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -245,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
|
||||
|
||||
@@ -6,6 +6,7 @@ package context
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -286,11 +287,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...)
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -6,7 +6,7 @@ package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -71,16 +71,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{}
|
||||
@@ -253,6 +243,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,11 @@ func (ctx *Context) RenderToString(name base.TplName, data map[string]any) (stri
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
@@ -85,7 +86,7 @@ func (r *Repository) CanCreateBranch() bool {
|
||||
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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,19 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
|
||||
return req
|
||||
}
|
||||
|
||||
type MockContextOption struct {
|
||||
Render context.Render
|
||||
}
|
||||
|
||||
// MockContext mock context for unit tests
|
||||
func MockContext(t *testing.T, reqPath string) (*context.Context, *httptest.ResponseRecorder) {
|
||||
func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*context.Context, *httptest.ResponseRecorder) {
|
||||
var opt MockContextOption
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
if opt.Render == nil {
|
||||
opt.Render = &MockRender{}
|
||||
}
|
||||
resp := httptest.NewRecorder()
|
||||
req := mockRequest(t, reqPath)
|
||||
base, baseCleanUp := context.NewBaseContext(resp, req)
|
||||
@@ -49,7 +60,7 @@ func MockContext(t *testing.T, reqPath string) (*context.Context, *httptest.Resp
|
||||
base.Data = middleware.GetContextData(req.Context())
|
||||
base.Locale = &translation.MockLocale{}
|
||||
|
||||
ctx := context.NewWebContext(base, &MockRender{}, nil)
|
||||
ctx := context.NewWebContext(base, opt.Render, nil)
|
||||
|
||||
chiCtx := chi.NewRouteContext()
|
||||
ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
|
||||
|
||||
+2
-2
@@ -123,9 +123,9 @@ func guessDelimiter(data []byte) rune {
|
||||
func FormatError(err error, locale translation.Locale) (string, error) {
|
||||
if perr, ok := err.(*stdcsv.ParseError); ok {
|
||||
if perr.Err == stdcsv.ErrFieldCount {
|
||||
return locale.Tr("repo.error.csv.invalid_field_count", perr.Line), nil
|
||||
return locale.TrString("repo.error.csv.invalid_field_count", perr.Line), nil
|
||||
}
|
||||
return locale.Tr("repo.error.csv.unexpected", perr.Line, perr.Column), nil
|
||||
return locale.TrString("repo.error.csv.unexpected", perr.Line, perr.Column), nil
|
||||
}
|
||||
|
||||
return "", err
|
||||
|
||||
@@ -7,6 +7,7 @@ package generate
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
@@ -38,19 +39,24 @@ func NewInternalToken() (string, error) {
|
||||
return internalToken, nil
|
||||
}
|
||||
|
||||
// NewJwtSecret generates a new value intended to be used for JWT secrets.
|
||||
func NewJwtSecret() ([]byte, error) {
|
||||
bytes := make([]byte, 32)
|
||||
_, err := io.ReadFull(rand.Reader, bytes)
|
||||
if err != nil {
|
||||
const defaultJwtSecretLen = 32
|
||||
|
||||
// DecodeJwtSecretBase64 decodes a base64 encoded jwt secret into bytes, and check its length
|
||||
func DecodeJwtSecretBase64(src string) ([]byte, error) {
|
||||
encoding := base64.RawURLEncoding
|
||||
decoded := make([]byte, encoding.DecodedLen(len(src))+3)
|
||||
if n, err := encoding.Decode(decoded, []byte(src)); err != nil {
|
||||
return nil, err
|
||||
} else if n != defaultJwtSecretLen {
|
||||
return nil, fmt.Errorf("invalid base64 decoded length: %d, expects: %d", n, defaultJwtSecretLen)
|
||||
}
|
||||
return bytes, nil
|
||||
return decoded[:defaultJwtSecretLen], nil
|
||||
}
|
||||
|
||||
// NewJwtSecretBase64 generates a new base64 encoded value intended to be used for JWT secrets.
|
||||
func NewJwtSecretBase64() ([]byte, string, error) {
|
||||
bytes, err := NewJwtSecret()
|
||||
// NewJwtSecretWithBase64 generates a jwt secret with its base64 encoded value intended to be used for saving into config file
|
||||
func NewJwtSecretWithBase64() ([]byte, string, error) {
|
||||
bytes := make([]byte, defaultJwtSecretLen)
|
||||
_, err := io.ReadFull(rand.Reader, bytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package generate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDecodeJwtSecretBase64(t *testing.T) {
|
||||
_, err := DecodeJwtSecretBase64("abcd")
|
||||
assert.ErrorContains(t, err, "invalid base64 decoded length")
|
||||
_, err = DecodeJwtSecretBase64(strings.Repeat("a", 64))
|
||||
assert.ErrorContains(t, err, "invalid base64 decoded length")
|
||||
|
||||
str32 := strings.Repeat("x", 32)
|
||||
encoded32 := base64.RawURLEncoding.EncodeToString([]byte(str32))
|
||||
decoded32, err := DecodeJwtSecretBase64(encoded32)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, str32, string(decoded32))
|
||||
}
|
||||
|
||||
func TestNewJwtSecretWithBase64(t *testing.T) {
|
||||
secret, encoded, err := NewJwtSecretWithBase64()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, secret, 32)
|
||||
decoded, err := DecodeJwtSecretBase64(encoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, secret, decoded)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (err ErrBranchNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrPushOutOfDate represents an error if merging fails due to unrelated histories
|
||||
// ErrPushOutOfDate represents an error if merging fails due to the base branch being updated
|
||||
type ErrPushOutOfDate struct {
|
||||
StdOut string
|
||||
StdErr string
|
||||
|
||||
+47
-30
@@ -39,36 +39,37 @@ var (
|
||||
gitVersion *version.Version
|
||||
)
|
||||
|
||||
// loadGitVersion returns current Git version from shell. Internal usage only.
|
||||
func loadGitVersion() (*version.Version, error) {
|
||||
// loadGitVersion tries to get the current git version and stores it into a global variable
|
||||
func loadGitVersion() error {
|
||||
// doesn't need RWMutex because it's executed by Init()
|
||||
if gitVersion != nil {
|
||||
return gitVersion, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil)
|
||||
if runErr != nil {
|
||||
return nil, runErr
|
||||
return runErr
|
||||
}
|
||||
|
||||
fields := strings.Fields(stdout)
|
||||
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
||||
if err == nil {
|
||||
gitVersion = ver
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseGitVersionLine(s string) (*version.Version, error) {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) < 3 {
|
||||
return nil, fmt.Errorf("invalid git version output: %s", stdout)
|
||||
return nil, fmt.Errorf("invalid git version: %q", s)
|
||||
}
|
||||
|
||||
var versionString string
|
||||
|
||||
// Handle special case on Windows.
|
||||
i := strings.Index(fields[2], "windows")
|
||||
if i >= 1 {
|
||||
versionString = fields[2][:i-1]
|
||||
} else {
|
||||
versionString = fields[2]
|
||||
// version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1"
|
||||
versionString := fields[2]
|
||||
if pos := strings.Index(versionString, "windows"); pos >= 1 {
|
||||
versionString = versionString[:pos-1]
|
||||
}
|
||||
|
||||
var err error
|
||||
gitVersion, err = version.NewVersion(versionString)
|
||||
return gitVersion, err
|
||||
return version.NewVersion(versionString)
|
||||
}
|
||||
|
||||
// SetExecutablePath changes the path of git executable and checks the file permission and version.
|
||||
@@ -83,8 +84,7 @@ func SetExecutablePath(path string) error {
|
||||
}
|
||||
GitExecutable = absPath
|
||||
|
||||
_, err = loadGitVersion()
|
||||
if err != nil {
|
||||
if err = loadGitVersion(); err != nil {
|
||||
return fmt.Errorf("unable to load git version: %w", err)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ func SetExecutablePath(path string) error {
|
||||
return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", gitVersion.Original(), RequiredVersion, moreHint)
|
||||
}
|
||||
|
||||
if err = checkGitVersionCompatibility(gitVersion); err != nil {
|
||||
return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", gitVersion.String(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -262,19 +265,18 @@ func syncGitConfig() (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user
|
||||
// however, some docker users and samba users find it difficult to configure their systems so that Gitea's git repositories are owned by the Gitea user. (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
|
||||
// see issue: https://github.com/go-gitea/gitea/issues/19455
|
||||
// Fundamentally the problem lies with the uid-gid-mapping mechanism for filesystems in docker on windows (and to a lesser extent samba).
|
||||
// Docker's configuration mechanism for local filesystems provides no way of setting this mapping and although there is a mechanism for setting this uid through using cifs mounting it is complicated and essentially undocumented
|
||||
// Thus the owner uid/gid for files on these filesystems will be marked as root.
|
||||
// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user.
|
||||
// However, some docker users and samba users find it difficult to configure their systems correctly,
|
||||
// so that Gitea's git repositories are owned by the Gitea user.
|
||||
// (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
|
||||
// See issue: https://github.com/go-gitea/gitea/issues/19455
|
||||
// As Gitea now always use its internal git config file, and access to the git repositories is managed through Gitea,
|
||||
// it is now safe to set "safe.directory=*" for internal usage only.
|
||||
// Please note: the wildcard "*" is only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later
|
||||
// Although only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later - this setting is tolerated by earlier versions
|
||||
// Although this setting is only supported by some new git versions, it is also tolerated by earlier versions
|
||||
if err := configAddNonExist("safe.directory", "*"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := configSet("core.longpaths", "true"); err != nil {
|
||||
return err
|
||||
@@ -307,8 +309,8 @@ func syncGitConfig() (err error) {
|
||||
|
||||
// CheckGitVersionAtLeast check git version is at least the constraint version
|
||||
func CheckGitVersionAtLeast(atLeast string) error {
|
||||
if _, err := loadGitVersion(); err != nil {
|
||||
return err
|
||||
if gitVersion == nil {
|
||||
panic("git module is not initialized") // it shouldn't happen
|
||||
}
|
||||
atLeastVersion, err := version.NewVersion(atLeast)
|
||||
if err != nil {
|
||||
@@ -320,6 +322,21 @@ func CheckGitVersionAtLeast(atLeast string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
||||
badVersions := []struct {
|
||||
Version *version.Version
|
||||
Reason string
|
||||
}{
|
||||
{version.Must(version.NewVersion("2.43.1")), "regression bug of GIT_FLUSH"},
|
||||
}
|
||||
for _, bad := range badVersions {
|
||||
if gitVer.Equal(bad.Version) {
|
||||
return errors.New(bad.Reason)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configSet(key, value string) error {
|
||||
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
|
||||
if err != nil && !err.IsExitCode(1) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -93,3 +94,25 @@ func TestSyncConfig(t *testing.T) {
|
||||
assert.True(t, gitConfigContains("[sync-test]"))
|
||||
assert.True(t, gitConfigContains("cfg-key-a = CfgValA"))
|
||||
}
|
||||
|
||||
func TestParseGitVersion(t *testing.T) {
|
||||
v, err := parseGitVersionLine("git version 2.29.3")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2.29.3", v.String())
|
||||
|
||||
v, err = parseGitVersionLine("git version 2.29.3.windows.1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2.29.3", v.String())
|
||||
|
||||
_, err = parseGitVersionLine("git version")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = parseGitVersionLine("git version windows")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCheckGitVersionCompatibility(t *testing.T) {
|
||||
assert.NoError(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.0"))))
|
||||
assert.ErrorContains(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.1"))), "regression bug of GIT_FLUSH")
|
||||
assert.NoError(t, checkGitVersionCompatibility(version.Must(version.NewVersion("2.43.2"))))
|
||||
}
|
||||
|
||||
@@ -143,19 +143,19 @@ func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int
|
||||
}
|
||||
|
||||
// Our "line" must look like: <commitid> SP (<parent> SP) * NUL
|
||||
commitIds := string(g.next)
|
||||
commitIDs := string(g.next)
|
||||
if g.buffull {
|
||||
more, err := g.rd.ReadString('\x00')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commitIds += more
|
||||
commitIDs += more
|
||||
}
|
||||
commitIds = commitIds[:len(commitIds)-1]
|
||||
splitIds := strings.Split(commitIds, " ")
|
||||
ret.CommitID = splitIds[0]
|
||||
if len(splitIds) > 1 {
|
||||
ret.ParentIDs = splitIds[1:]
|
||||
commitIDs = commitIDs[:len(commitIDs)-1]
|
||||
splitIDs := strings.Split(commitIDs, " ")
|
||||
ret.CommitID = splitIDs[0]
|
||||
if len(splitIDs) > 1 {
|
||||
ret.ParentIDs = splitIDs[1:]
|
||||
}
|
||||
|
||||
// now read the next "line"
|
||||
|
||||
+1
-1
@@ -271,7 +271,7 @@ func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error
|
||||
return time.Time{}, err
|
||||
}
|
||||
commitTime := strings.TrimSpace(stdout)
|
||||
return time.Parse(GitTimeLayout, commitTime)
|
||||
return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime)
|
||||
}
|
||||
|
||||
// DivergeObject represents commit count diverging commits
|
||||
|
||||
@@ -183,11 +183,7 @@ func parseTagRef(objectFormat ObjectFormat, ref map[string]string) (tag *Tag, er
|
||||
}
|
||||
}
|
||||
|
||||
tag.Tagger, err = newSignatureFromCommitline([]byte(ref["creator"]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tagger: %w", err)
|
||||
}
|
||||
|
||||
tag.Tagger = parseSignatureFromCommitLine(ref["creator"])
|
||||
tag.Message = ref["contents"]
|
||||
// strip PGP signature if present in contents field
|
||||
pgpStart := strings.Index(tag.Message, beginpgp)
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestRepository_parseTagRef(t *testing.T) {
|
||||
ID: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
|
||||
Object: MustIDFromString("ab23e4b7f4cd0caafe0174c0e7ef6d651ba72889"),
|
||||
Type: "commit",
|
||||
Tagger: parseAuthorLine(t, "Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
|
||||
Signature: nil,
|
||||
},
|
||||
@@ -256,7 +256,7 @@ func TestRepository_parseTagRef(t *testing.T) {
|
||||
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
|
||||
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
|
||||
Type: "tag",
|
||||
Tagger: parseAuthorLine(t, "Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md\n",
|
||||
Signature: nil,
|
||||
},
|
||||
@@ -314,7 +314,7 @@ qbHDASXl
|
||||
ID: MustIDFromString("8c68a1f06fc59c655b7e3905b159d761e91c53c9"),
|
||||
Object: MustIDFromString("3325fd8a973321fd59455492976c042dde3fd1ca"),
|
||||
Type: "tag",
|
||||
Tagger: parseAuthorLine(t, "Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Tagger: parseSignatureFromCommitLine("Foo Bar <foo@bar.com> 1565789218 +0300"),
|
||||
Message: "Add changelog of v1.9.1 (#7859)\n\n* add changelog of v1.9.1\n* Update CHANGELOG.md",
|
||||
Signature: &CommitGPGSignature{
|
||||
Signature: `-----BEGIN PGP SIGNATURE-----
|
||||
@@ -363,14 +363,3 @@ Add changelog of v1.9.1 (#7859)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseAuthorLine(t *testing.T, committer string) *Signature {
|
||||
t.Helper()
|
||||
|
||||
sig, err := newSignatureFromCommitline([]byte(committer))
|
||||
if err != nil {
|
||||
t.Fatalf("parse author line '%s': %v", committer, err)
|
||||
}
|
||||
|
||||
return sig
|
||||
}
|
||||
|
||||
@@ -4,7 +4,46 @@
|
||||
|
||||
package git
|
||||
|
||||
const (
|
||||
// GitTimeLayout is the (default) time layout used by git.
|
||||
GitTimeLayout = "Mon Jan _2 15:04:05 2006 -0700"
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// Helper to get a signature from the commit line, which looks like:
|
||||
//
|
||||
// full name <user@example.com> 1378823654 +0200
|
||||
//
|
||||
// Haven't found the official reference for the standard format yet.
|
||||
// This function never fails, if the "line" can't be parsed, it returns a default Signature with "zero" time.
|
||||
func parseSignatureFromCommitLine(line string) *Signature {
|
||||
sig := &Signature{}
|
||||
s1, sx, ok1 := strings.Cut(line, " <")
|
||||
s2, s3, ok2 := strings.Cut(sx, "> ")
|
||||
if !ok1 || !ok2 {
|
||||
sig.Name = line
|
||||
return sig
|
||||
}
|
||||
sig.Name, sig.Email = s1, s2
|
||||
|
||||
if strings.Count(s3, " ") == 1 {
|
||||
ts, tz, _ := strings.Cut(s3, " ")
|
||||
seconds, _ := strconv.ParseInt(ts, 10, 64)
|
||||
if tzTime, err := time.Parse("-0700", tz); err == nil {
|
||||
sig.When = time.Unix(seconds, 0).In(tzTime.Location())
|
||||
}
|
||||
} else {
|
||||
// the old gitea code tried to parse the date in a few different formats, but it's not clear why.
|
||||
// according to public document, only the standard format "timestamp timezone" could be found, so drop other formats.
|
||||
log.Error("suspicious commit line format: %q", line)
|
||||
for _, fmt := range []string{ /*"Mon Jan _2 15:04:05 2006 -0700"*/ } {
|
||||
if t, err := time.Parse(fmt, s3); err == nil {
|
||||
sig.When = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
@@ -7,52 +7,8 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// Signature represents the Author or Committer information.
|
||||
type Signature = object.Signature
|
||||
|
||||
// Helper to get a signature from the commit line, which looks like these:
|
||||
//
|
||||
// author Patrick Gundlach <gundlach@speedata.de> 1378823654 +0200
|
||||
// author Patrick Gundlach <gundlach@speedata.de> Thu, 07 Apr 2005 22:13:13 +0200
|
||||
//
|
||||
// but without the "author " at the beginning (this method should)
|
||||
// be used for author and committer.
|
||||
//
|
||||
// FIXME: include timezone for timestamp!
|
||||
func newSignatureFromCommitline(line []byte) (_ *Signature, err error) {
|
||||
sig := new(Signature)
|
||||
emailStart := bytes.IndexByte(line, '<')
|
||||
if emailStart > 0 { // Empty name has already occurred, even if it shouldn't
|
||||
sig.Name = strings.TrimSpace(string(line[:emailStart-1]))
|
||||
}
|
||||
emailEnd := bytes.IndexByte(line, '>')
|
||||
sig.Email = string(line[emailStart+1 : emailEnd])
|
||||
|
||||
// Check date format.
|
||||
if len(line) > emailEnd+2 {
|
||||
firstChar := line[emailEnd+2]
|
||||
if firstChar >= 48 && firstChar <= 57 {
|
||||
timestop := bytes.IndexByte(line[emailEnd+2:], ' ')
|
||||
timestring := string(line[emailEnd+2 : emailEnd+2+timestop])
|
||||
seconds, _ := strconv.ParseInt(timestring, 10, 64)
|
||||
sig.When = time.Unix(seconds, 0)
|
||||
} else {
|
||||
sig.When, err = time.Parse(GitTimeLayout, string(line[emailEnd+2:]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to unix 0 time
|
||||
sig.When = time.Unix(0, 0)
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
@@ -7,21 +7,17 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// Signature represents the Author or Committer information.
|
||||
// Signature represents the Author, Committer or Tagger information.
|
||||
type Signature struct {
|
||||
// Name represents a person name. It is an arbitrary string.
|
||||
Name string
|
||||
// Email is an email, but it cannot be assumed to be well-formed.
|
||||
Email string
|
||||
// When is the timestamp of the signature.
|
||||
When time.Time
|
||||
Name string // the committer name, it can be anything
|
||||
Email string // the committer email, it can be anything
|
||||
When time.Time // the timestamp of the signature
|
||||
}
|
||||
|
||||
func (s *Signature) String() string {
|
||||
@@ -30,71 +26,5 @@ func (s *Signature) String() string {
|
||||
|
||||
// Decode decodes a byte array representing a signature to signature
|
||||
func (s *Signature) Decode(b []byte) {
|
||||
sig, _ := newSignatureFromCommitline(b)
|
||||
s.Email = sig.Email
|
||||
s.Name = sig.Name
|
||||
s.When = sig.When
|
||||
}
|
||||
|
||||
// Helper to get a signature from the commit line, which looks like these:
|
||||
//
|
||||
// author Patrick Gundlach <gundlach@speedata.de> 1378823654 +0200
|
||||
// author Patrick Gundlach <gundlach@speedata.de> Thu, 07 Apr 2005 22:13:13 +0200
|
||||
//
|
||||
// but without the "author " at the beginning (this method should)
|
||||
// be used for author and committer.
|
||||
// FIXME: there are a lot of "return sig, err" (but the err is also nil), that's the old behavior, to avoid breaking
|
||||
func newSignatureFromCommitline(line []byte) (sig *Signature, err error) {
|
||||
sig = new(Signature)
|
||||
emailStart := bytes.LastIndexByte(line, '<')
|
||||
emailEnd := bytes.LastIndexByte(line, '>')
|
||||
if emailStart == -1 || emailEnd == -1 || emailEnd < emailStart {
|
||||
return sig, err
|
||||
}
|
||||
|
||||
if emailStart > 0 { // Empty name has already occurred, even if it shouldn't
|
||||
sig.Name = strings.TrimSpace(string(line[:emailStart-1]))
|
||||
}
|
||||
sig.Email = string(line[emailStart+1 : emailEnd])
|
||||
|
||||
hasTime := emailEnd+2 < len(line)
|
||||
if !hasTime {
|
||||
return sig, err
|
||||
}
|
||||
|
||||
// Check date format.
|
||||
firstChar := line[emailEnd+2]
|
||||
if firstChar >= 48 && firstChar <= 57 {
|
||||
idx := bytes.IndexByte(line[emailEnd+2:], ' ')
|
||||
if idx < 0 {
|
||||
return sig, err
|
||||
}
|
||||
|
||||
timestring := string(line[emailEnd+2 : emailEnd+2+idx])
|
||||
seconds, _ := strconv.ParseInt(timestring, 10, 64)
|
||||
sig.When = time.Unix(seconds, 0)
|
||||
|
||||
idx += emailEnd + 3
|
||||
if idx >= len(line) || idx+5 > len(line) {
|
||||
return sig, err
|
||||
}
|
||||
|
||||
timezone := string(line[idx : idx+5])
|
||||
tzhours, err1 := strconv.ParseInt(timezone[0:3], 10, 64)
|
||||
tzmins, err2 := strconv.ParseInt(timezone[3:], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return sig, err
|
||||
}
|
||||
if tzhours < 0 {
|
||||
tzmins *= -1
|
||||
}
|
||||
tz := time.FixedZone("", int(tzhours*60*60+tzmins*60))
|
||||
sig.When = sig.When.In(tz)
|
||||
} else {
|
||||
sig.When, err = time.Parse(GitTimeLayout, string(line[emailEnd+2:]))
|
||||
if err != nil {
|
||||
return sig, err
|
||||
}
|
||||
}
|
||||
return sig, err
|
||||
*s = *parseSignatureFromCommitLine(util.UnsafeBytesToString(b))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseSignatureFromCommitLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want *Signature
|
||||
}{
|
||||
{
|
||||
line: "a b <c@d.com> 12345 +0100",
|
||||
want: &Signature{
|
||||
Name: "a b",
|
||||
Email: "c@d.com",
|
||||
When: time.Unix(12345, 0).In(time.FixedZone("", 3600)),
|
||||
},
|
||||
},
|
||||
{
|
||||
line: "bad line",
|
||||
want: &Signature{Name: "bad line"},
|
||||
},
|
||||
{
|
||||
line: "bad < line",
|
||||
want: &Signature{Name: "bad < line"},
|
||||
},
|
||||
{
|
||||
line: "bad > line",
|
||||
want: &Signature{Name: "bad > line"},
|
||||
},
|
||||
{
|
||||
line: "bad-line <name@example.com>",
|
||||
want: &Signature{Name: "bad-line <name@example.com>"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got := parseSignatureFromCommitLine(test.line)
|
||||
assert.EqualValues(t, test.want, got)
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -7,6 +7,8 @@ import (
|
||||
"bytes"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,11 +61,7 @@ l:
|
||||
// A commit can have one or more parents
|
||||
tag.Type = string(line[spacepos+1:])
|
||||
case "tagger":
|
||||
sig, err := newSignatureFromCommitline(line[spacepos+1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag.Tagger = sig
|
||||
tag.Tagger = parseSignatureFromCommitLine(util.UnsafeBytesToString(line[spacepos+1:]))
|
||||
}
|
||||
nextline += eol + 1
|
||||
case eol == 0:
|
||||
|
||||
@@ -180,11 +180,17 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st
|
||||
}
|
||||
|
||||
if len(reqs) > 0 {
|
||||
_, err := b.inner.Client.Bulk().
|
||||
Index(b.inner.VersionedIndexName()).
|
||||
Add(reqs...).
|
||||
Do(ctx)
|
||||
return err
|
||||
esBatchSize := 50
|
||||
|
||||
for i := 0; i < len(reqs); i += esBatchSize {
|
||||
_, err := b.inner.Client.Bulk().
|
||||
Index(b.inner.VersionedIndexName()).
|
||||
Add(reqs[i:min(i+esBatchSize, len(reqs))]...).
|
||||
Do(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
|
||||
}
|
||||
|
||||
err = transferAdapter.Upload(ctx, link, object.Pointer, content)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -804,7 +804,7 @@ func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
|
||||
// indicate that in the text by appending (comment)
|
||||
if m[4] != -1 && m[5] != -1 {
|
||||
if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
|
||||
text += " " + locale.Tr("repo.from_comment")
|
||||
text += " " + locale.TrString("repo.from_comment")
|
||||
} else {
|
||||
text += " (comment)"
|
||||
}
|
||||
|
||||
@@ -182,12 +182,7 @@ func IsColorPreview(node ast.Node) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
const (
|
||||
AttentionNote string = "Note"
|
||||
AttentionWarning string = "Warning"
|
||||
)
|
||||
|
||||
// Attention is an inline for a color preview
|
||||
// Attention is an inline for an attention
|
||||
type Attention struct {
|
||||
ast.BaseInline
|
||||
AttentionType string
|
||||
|
||||
@@ -53,7 +53,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
||||
}
|
||||
}
|
||||
|
||||
attentionMarkedBlockquotes := make(container.Set[*ast.Blockquote])
|
||||
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
@@ -197,18 +196,55 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
||||
if css.ColorHandler(strings.ToLower(string(colorContent))) {
|
||||
v.AppendChild(v, NewColorPreview(colorContent))
|
||||
}
|
||||
case *ast.Emphasis:
|
||||
// check if inside blockquote for attention, expected hierarchy is
|
||||
// Emphasis < Paragraph < Blockquote
|
||||
blockquote, isInBlockquote := n.Parent().Parent().(*ast.Blockquote)
|
||||
if isInBlockquote && !attentionMarkedBlockquotes.Contains(blockquote) {
|
||||
fullText := string(n.Text(reader.Source()))
|
||||
if fullText == AttentionNote || fullText == AttentionWarning {
|
||||
v.SetAttributeString("class", []byte("attention-"+strings.ToLower(fullText)))
|
||||
v.Parent().InsertBefore(v.Parent(), v, NewAttention(fullText))
|
||||
attentionMarkedBlockquotes.Add(blockquote)
|
||||
}
|
||||
case *ast.Blockquote:
|
||||
// We only want attention blockquotes when the AST looks like:
|
||||
// Text: "["
|
||||
// Text: "!TYPE"
|
||||
// Text(SoftLineBreak): "]"
|
||||
|
||||
// grab these nodes and make sure we adhere to the attention blockquote structure
|
||||
firstParagraph := v.FirstChild()
|
||||
if firstParagraph.ChildCount() < 3 {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
firstTextNode, ok := firstParagraph.FirstChild().(*ast.Text)
|
||||
if !ok || string(firstTextNode.Segment.Value(reader.Source())) != "[" {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
secondTextNode, ok := firstTextNode.NextSibling().(*ast.Text)
|
||||
if !ok || !attentionTypeRE.MatchString(string(secondTextNode.Segment.Value(reader.Source()))) {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
thirdTextNode, ok := secondTextNode.NextSibling().(*ast.Text)
|
||||
if !ok || string(thirdTextNode.Segment.Value(reader.Source())) != "]" {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// grab attention type from markdown source
|
||||
attentionType := strings.ToLower(strings.TrimPrefix(string(secondTextNode.Segment.Value(reader.Source())), "!"))
|
||||
|
||||
// color the blockquote
|
||||
v.SetAttributeString("class", []byte("gt-py-3 attention attention-"+attentionType))
|
||||
|
||||
// create an emphasis to make it bold
|
||||
emphasis := ast.NewEmphasis(2)
|
||||
emphasis.SetAttributeString("class", []byte("attention-"+attentionType))
|
||||
firstParagraph.InsertBefore(firstParagraph, firstTextNode, emphasis)
|
||||
|
||||
// capitalize first letter
|
||||
attentionText := ast.NewString([]byte(strings.ToUpper(string(attentionType[0])) + attentionType[1:]))
|
||||
|
||||
// replace the ![TYPE] with icon+Type
|
||||
emphasis.AppendChild(emphasis, attentionText)
|
||||
for i := 0; i < 2; i++ {
|
||||
lineBreak := ast.NewText()
|
||||
lineBreak.SetSoftLineBreak(true)
|
||||
firstParagraph.InsertAfter(firstParagraph, emphasis, lineBreak)
|
||||
}
|
||||
firstParagraph.InsertBefore(firstParagraph, emphasis, NewAttention(attentionType))
|
||||
firstParagraph.RemoveChild(firstParagraph, firstTextNode)
|
||||
firstParagraph.RemoveChild(firstParagraph, secondTextNode)
|
||||
firstParagraph.RemoveChild(firstParagraph, thirdTextNode)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
@@ -339,17 +375,23 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
|
||||
// renderAttention renders a quote marked with i.e. "> **Note**" or "> **Warning**" with a corresponding svg
|
||||
func (r *HTMLRenderer) renderAttention(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString(`<span class="attention-icon attention-`)
|
||||
_, _ = w.WriteString(`<span class="gt-mr-2 gt-vm attention-`)
|
||||
n := node.(*Attention)
|
||||
_, _ = w.WriteString(strings.ToLower(n.AttentionType))
|
||||
_, _ = w.WriteString(`">`)
|
||||
|
||||
var octiconType string
|
||||
switch n.AttentionType {
|
||||
case AttentionNote:
|
||||
case "note":
|
||||
octiconType = "info"
|
||||
case AttentionWarning:
|
||||
case "tip":
|
||||
octiconType = "light-bulb"
|
||||
case "important":
|
||||
octiconType = "report"
|
||||
case "warning":
|
||||
octiconType = "alert"
|
||||
case "caution":
|
||||
octiconType = "stop"
|
||||
}
|
||||
_, _ = w.WriteString(string(svg.RenderHTML("octicon-" + octiconType)))
|
||||
} else {
|
||||
@@ -417,7 +459,10 @@ func (r *HTMLRenderer) renderSummary(w util.BufWriter, source []byte, node ast.N
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
var validNameRE = regexp.MustCompile("^[a-z ]+$")
|
||||
var (
|
||||
validNameRE = regexp.MustCompile("^[a-z ]+$")
|
||||
attentionTypeRE = regexp.MustCompile("^!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)$")
|
||||
)
|
||||
|
||||
func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
@@ -440,7 +485,6 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node
|
||||
|
||||
var err error
|
||||
_, err = w.WriteString(fmt.Sprintf(`<i class="icon %s"></i>`, name))
|
||||
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]str
|
||||
details.SetAttributeString(k, []byte(v))
|
||||
}
|
||||
|
||||
summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).Tr("toc"))))
|
||||
summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc"))))
|
||||
details.AppendChild(details, summary)
|
||||
ul := ast.NewList('-')
|
||||
details.AppendChild(details, ul)
|
||||
|
||||
@@ -133,18 +133,18 @@ type Writer struct {
|
||||
Ctx *markup.RenderContext
|
||||
}
|
||||
|
||||
const mailto = "mailto:"
|
||||
|
||||
func (r *Writer) resolveLink(l org.RegularLink) string {
|
||||
link := html.EscapeString(l.URL)
|
||||
if l.Protocol == "file" {
|
||||
link = link[len("file:"):]
|
||||
}
|
||||
if len(link) > 0 && !markup.IsLinkStr(link) &&
|
||||
link[0] != '#' && !strings.HasPrefix(link, mailto) {
|
||||
func (r *Writer) resolveLink(kind, link string) string {
|
||||
link = strings.TrimPrefix(link, "file:")
|
||||
if !strings.HasPrefix(link, "#") && // not a URL fragment
|
||||
!markup.IsLinkStr(link) && // not an absolute URL
|
||||
!strings.HasPrefix(link, "mailto:") {
|
||||
if kind == "regular" {
|
||||
// orgmode reports the link kind as "regular" for "[[ImageLink.svg][The Image Desc]]"
|
||||
// so we need to try to guess the link kind again here
|
||||
kind = org.RegularLink{URL: link}.Kind()
|
||||
}
|
||||
base := r.Ctx.Links.Base
|
||||
switch l.Kind() {
|
||||
case "image", "video":
|
||||
if kind == "image" || kind == "video" {
|
||||
base = r.Ctx.Links.ResolveMediaLink(r.Ctx.IsWiki)
|
||||
}
|
||||
link = util.URLJoin(base, link)
|
||||
@@ -154,29 +154,29 @@ func (r *Writer) resolveLink(l org.RegularLink) string {
|
||||
|
||||
// WriteRegularLink renders images, links or videos
|
||||
func (r *Writer) WriteRegularLink(l org.RegularLink) {
|
||||
link := r.resolveLink(l)
|
||||
link := r.resolveLink(l.Kind(), l.URL)
|
||||
|
||||
// Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
|
||||
switch l.Kind() {
|
||||
case "image":
|
||||
if l.Description == nil {
|
||||
fmt.Fprintf(r, `<img src="%s" alt="%s" />`, link, link)
|
||||
_, _ = fmt.Fprintf(r, `<img src="%s" alt="%s" />`, link, link)
|
||||
} else {
|
||||
imageSrc := r.resolveLink(l.Description[0].(org.RegularLink))
|
||||
fmt.Fprintf(r, `<a href="%s"><img src="%s" alt="%s" /></a>`, link, imageSrc, imageSrc)
|
||||
imageSrc := r.resolveLink(l.Kind(), org.String(l.Description...))
|
||||
_, _ = fmt.Fprintf(r, `<a href="%s"><img src="%s" alt="%s" /></a>`, link, imageSrc, imageSrc)
|
||||
}
|
||||
case "video":
|
||||
if l.Description == nil {
|
||||
fmt.Fprintf(r, `<video src="%s">%s</video>`, link, link)
|
||||
_, _ = fmt.Fprintf(r, `<video src="%s">%s</video>`, link, link)
|
||||
} else {
|
||||
videoSrc := r.resolveLink(l.Description[0].(org.RegularLink))
|
||||
fmt.Fprintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
|
||||
videoSrc := r.resolveLink(l.Kind(), org.String(l.Description...))
|
||||
_, _ = fmt.Fprintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
|
||||
}
|
||||
default:
|
||||
description := link
|
||||
if l.Description != nil {
|
||||
description = r.WriteNodesAsString(l.Description...)
|
||||
}
|
||||
fmt.Fprintf(r, `<a href="%s">%s</a>`, link, description)
|
||||
_, _ = fmt.Fprintf(r, `<a href="%s">%s</a>`, link, description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,21 @@ import (
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
AppURL = "http://localhost:3000/"
|
||||
Repo = "gogits/gogs"
|
||||
AppSubURL = AppURL + Repo + "/"
|
||||
)
|
||||
const AppURL = "http://localhost:3000/"
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
setting.AppSubURL = AppSubURL
|
||||
|
||||
test := func(input, expected string) {
|
||||
buffer, err := RenderString(&markup.RenderContext{
|
||||
Ctx: git.DefaultContext,
|
||||
Links: markup.Links{
|
||||
Base: setting.AppSubURL,
|
||||
Base: "/relative-path",
|
||||
BranchPath: "branch/main",
|
||||
},
|
||||
}, input)
|
||||
assert.NoError(t, err)
|
||||
@@ -38,32 +33,30 @@ func TestRender_StandardLinks(t *testing.T) {
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
|
||||
lnk := util.URLJoin(AppSubURL, "WikiPage")
|
||||
test("[[WikiPage][WikiPage]]",
|
||||
`<p><a href="`+lnk+`">WikiPage</a></p>`)
|
||||
test("[[WikiPage][The WikiPage Desc]]",
|
||||
`<p><a href="/relative-path/WikiPage">The WikiPage Desc</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="/relative-path/media/branch/main/ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
setting.AppSubURL = AppSubURL
|
||||
|
||||
test := func(input, expected string) {
|
||||
buffer, err := RenderString(&markup.RenderContext{
|
||||
Ctx: git.DefaultContext,
|
||||
Links: markup.Links{
|
||||
Base: setting.AppSubURL,
|
||||
Base: "./relative-path",
|
||||
},
|
||||
}, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
url := "../../.images/src/02/train.jpg"
|
||||
result := util.URLJoin(AppSubURL, url)
|
||||
|
||||
test("[[file:"+url+"]]",
|
||||
`<p><img src="`+result+`" alt="`+result+`" /></p>`)
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src=".images/src/02/train.jpg" alt=".images/src/02/train.jpg" /></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
`<p><img src="relative-path/train.jpg" alt="relative-path/train.jpg" /></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
@@ -80,11 +73,20 @@ func TestRender_Media(t *testing.T) {
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg" /></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg" /></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg" /></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg" /></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
setting.AppURL = AppURL
|
||||
setting.AppSubURL = AppSubURL
|
||||
|
||||
test := func(input, expected string) {
|
||||
buffer, err := RenderString(&markup.RenderContext{
|
||||
|
||||
@@ -64,9 +64,10 @@ func createDefaultPolicy() *bluemonday.Policy {
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^color-preview$`)).OnElements("span")
|
||||
|
||||
// For attention
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^gt-py-3 attention attention-\w+$`)).OnElements("blockquote")
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-\w+$`)).OnElements("strong")
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^attention-icon attention-\w+$`)).OnElements("span", "strong")
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^svg octicon-\w+$`)).OnElements("svg")
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^gt-mr-2 gt-vm attention-\w+$`)).OnElements("span", "strong")
|
||||
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^svg octicon-(\w|-)+$`)).OnElements("svg")
|
||||
policy.AllowAttrs("viewBox", "width", "height", "aria-hidden").OnElements("svg")
|
||||
policy.AllowAttrs("fill-rule", "d").OnElements("path")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package migration
|
||||
|
||||
// Messenger is a formatting function similar to i18n.Tr
|
||||
// Messenger is a formatting function similar to i18n.TrString
|
||||
type Messenger func(key string, args ...any)
|
||||
|
||||
// NilMessenger represents an empty formatting function
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user