diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 990f3c8e07..e9a4883e31 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -49,7 +49,7 @@ jobs: run: | REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "Cleaned name is ${REF_NAME}" - echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" + echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: configure aws uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 04fffd4a4e..0d9563b34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,172 @@ This changelog goes through the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.com). +## [1.22.0](https://github.com/go-gitea/gitea/releases/tag/v1.22.0) - 2024-05-27 + +This release stands as a monumental milestone in our development journey with a record-breaking incorporation of [1528](https://github.com/go-gitea/gitea/pulls?q=is%3Apr+milestone%3A1.22.0+is%3Amerged) pull requests. It marks the most extensive update in Gitea's history, showcasing a plethora of new features and infrastructure improvements. + +Noteworthy advancements in this release include the introduction of `HTMX` and `Tailwind`, signaling a strategic shift as we gradually phase out `jquery` and `Fomantic UI`. These changes reflect our commitment to embracing modern technologies and enhancing the user experience. + +Key highlights of this release encompass significant changes categorized under `BREAKING`, `FEATURES`, `ENHANCEMENTS`, and `PERFORMANCE`, each contributing to a more robust and efficient Gitea platform. + +* BREAKING + * Improve reverse proxy documents and clarify the AppURL guessing behavior (#31003) (#31020) + * Remember log in for a month by default (#30150) + * Breaking summary for template refactoring (#29395) + * All custom templates need to follow these changes + * Recommend/convert to use case-sensitive collation for MySQL/MSSQL (#28662) + * Make offline mode as default to not connect external avatar service by default (#28548) + * Include public repos in the doer's dashboard for issue search (#28304) + * Use restricted sanitizer for repository description (#28141) + * Support storage base path as prefix (#27827) + * Enhanced auth token / remember me (#27606) + * Rename the default themes to `gitea-light`, `gitea-dark`, `gitea-auto` (#27419) + * If you didn't see the new themes, please remove the `[ui].THEMES` config option from `app.ini` + * Require MySQL 8.0, PostgreSQL 12, MSSQL 2012 (#27337) +* FEATURES + * Allow everyone to read or write a wiki by a repo unit setting (#30495) + * Use raw Wiki links for non-renderable Wiki files (#30273) + * Render embedded code preview by permalink in markdown (#30234) (#30249) + * Support repo code search without setting up an indexer (#29998) + * Support pasting URLs over markdown text (#29566) + * Allow to change primary email before account activation (#29412) + * Customizable "Open with" applications for repository clone (#29320) + * Allow options to disable user deletion from the interface on app.ini (#29275) + * Extend issue template YAML engine (#29274) + * Add support for `linguist-detectable` and `linguist-documentation` (#29267) + * Implement code frequency graph (#29191) + * Show commit status for releases (#29149) + * Add user blocking (#29028) + * Actions Artifacts v4 backend (#28965) + * Add merge style `fast-forward-only` (#28954) + * Retarget depending pulls when the parent branch is deleted (#28686) + * Add global setting on how timestamps should be rendered (#28657) + * Implement actions badge SVGs (#28102) + * Add skip ci functionality (#28075) + * Show latest commit for file (#28067) + * Allow to sync tags from the admin dashboard (#28045) + * Add Profile Readme for Organisations (#27955) + * Implement contributors graph (#27882) + * Artifact deletion in actions ui (#27172) + * Add API routes to get runner registration token (#27144) + * Add support for forking single branch (#25821) + * Add support for sha256 repositories (#23894) + * Add admin API route for managing user's badges (#23106) +* ENHANCEMENTS + * Make gitea webhooks openproject compatible (#28435) (#31081) + * Support using label names when changing issue labels (#30943) (#30958) + * Fix various problems around project board view (#30696) (#30902) + * Improve context popup rendering (#30824) (#30829) + * Allow to save empty comment (#30706) + * Prevent allow/reject reviews on merged/closed PRs (#30686) + * Initial support for colorblindness-friendly themes (#30625) + * Some NuGet package enhancements (#30280) (#30324) + * Markup color and font size fixes (#30282) (#30310) + * Show 12 lines in markup code preview (#30255) (#30257) + * Add `[other].SHOW_FOOTER_POWERED_BY` setting to hide `Powered by` (#30253) + * Pulse page improvements (#30149) + * Render code tags in commit messages (#30146) + * Prevent re-review and dismiss review actions on closed and merged PRs (#30065) + * Cancel previous runs of the same PR automatically (#29961) + * Drag-and-drop improvements for projects and issue pins (#29875) + * Add default board to new projects, remove uncategorized pseudo-board (#29874) + * Prevent layout shift in `` items (#29831) + * Add skip ci support for pull request title (#29774) + * Add more stats tables (#29730) + * Update API to return 'source_id' for users (#29718) + * Determine fuzziness of bleve indexer by keyword length (#29706) + * Expose fuzzy search for issues/pulls (#29701) + * Put an edit file button on pull request files to allow a quick operation (#29697) + * Fix action runner offline label padding (#29691) + * Update allowed attachment types (#29688) + * Completely style the webkit autofill (#29683) + * Highlight archived labels (#29680) + * Add a warning for disallowed email domains (#29658) + * Set user's 24h preference from their current OS locale (#29651) + * Add setting to disable user features when user login type is not plain (#29615) + * Improve natural sort (#29611) + * Make wiki default branch name changeable (#29603) + * Unify search boxes (#29530) + * Add support for API blob upload of release attachments (#29507) + * Detect broken git hooks (#29494) + * Sync branches to DB immediately when handling git hook calling (#29493) + * Allow options to disable user GPG key configuration from the interface on app.ini (#29486) + * Allow options to disable user SSH key configuration from the interface on app.ini (#29447) + * Use relative links for commits, mentions, and issues in markdown (#29427) + * Add ``, rename webcomponents (#29400) + * Include resource state events in Gitlab downloads (#29382) + * Properly migrate target branch change GitLab comment (#29340) + * Recolor dark theme to blue shade (#29283) + * Partially enable MSSQL case-sensitive collation support (#29238) + * Auto-update the system status in the admin dashboard (#29163) + * Integrate alpine `noarch` packages into other architectures index (#29137) + * Document how the TOC election process works (#29135) + * Tweak repo header (#29134) + * Make blockquote border size less aggressive (#29124) + * Downscale pasted PNG images based on metadata (#29123) + * Show `View at this point in history` for every commit (#29122) + * Add support for action artifact serve direct (#29120) + * Change webhook-type in create-view (#29114) + * Drop "@" from the email sender to avoid spam filters (#29109) + * Allow non-admin users to delete review requests (#29057) + * Improve user search display name (#29002) + * Include username in email headers (#28981) + * Show whether a PR is WIP inside popups (#28975) + * Also match weakly validated ETags (#28957) + * Support nuspec manifest download for Nuget packages (#28921) + * Fix hardcoded GitHub icon used as migrated release avatar (#28910) + * Propagate install_if and provider_priority to APKINDEX (#28899) + * Add artifacts v4 JWT to job message and accept it (#28885) + * Enable/disable owner and repo projects independently (#28805) + * Add non-JS fallback for reaction tooltips (#28785) + * Add the ability to see open and closed issues at the same time (#28757) + * Move sign-in labels to be above inputs (#28753) + * Display the latest sync time for pull mirrors on the repo page (#28712) + * Show in Web UI if the file is vendored and generated (#28620) + * Add orphaned topic consistency check (#28507) + * Add branch protection setting for ignoring stale approvals (#28498) + * Add option to set language in admin user view (#28449) + * Fix incorrect run order of action jobs (#28367) + * Add missing exclusive in advanced label options (#28322) + * Added instance-level variables (#28115) + * Add edit option for README.md (#28071) + * Fix link to `Code` tab on wiki commits (#28041) + * Allow to set explore page default sort (#27951) + * Improve PR diff view on mobile (#27883) + * Properly migrate automatic merge GitLab comments (#27873) + * Display issue task list on project cards (#27865) + * Add Index to pull_auto_merge.doer_id (#27811) + * Fix display member unit in the menu bar if there are no hidden members in public org (#27795) + * List all Debian package versions in `Packages` (#27786) + * Allow pull requests Manually Merged option to be used by non-admins (#27780) + * Only show diff file tree when more than one file changed (#27775) + * Show placeholder email in privacy popup (#27770) + * Revamp repo header (#27760) + * Add `must-change-password` command line parameter (#27626) + * Unify password changing and invalidate auth tokens (#27625) + * Add border to file tree 'sub-items' and add padding to 'item-file' (#27593) + * Add slow SQL query warning (#27545) + * Pre-register OAuth application for tea (#27509) + * Differentiate between `push` and `pull` `mirror sync in progress` (#27390) + * Link to file from its history (#27354) + * Add a shortcut to user's profile page to admin user details (#27299) + * Doctor: delete action entries without existing user (#27292) + * Show total TrackedTime on issue/pull/milestone lists (#26672) + * Don't show the new pull request button when the page is not compare pull (#26431) + * Add `Hide/Show all checks` button to commit status check (#26284) + * Improvements of releases list and tags list (#25859) +* PERFORMANCE + * Fix package list performance (#30520) (#30616) + * Add commit status summary table to reduce query from commit status table (#30223) + * Refactor markup/csv: don't read all to memory (#29760) + * Lazy load object format with command line and don't do it in OpenRepository (#29712) + * Add cache for branch divergence on branch list page (#29577) + * Do some performance optimization for issues list and view issue/pull (#29515) + * Cache repository default branch commit status to reduce query on commit status table (#29444) + * Use `crypto/sha256` (#29386) + * Some performance optimization on the dashboard and issues page (#29010) + * Add combined index for issue_user.uid and issue_id (#28080) + ## [1.21.11](https://github.com/go-gitea/gitea/releases/tag/v1.21.11) - 2024-04-07 * SECURITY diff --git a/Dockerfile b/Dockerfile index b647c0cd59..21a8ce0d75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM docker.io/library/golang:1.22-alpine3.19 AS build-env +FROM docker.io/library/golang:1.22-alpine3.20 AS build-env ARG GOPROXY ENV GOPROXY ${GOPROXY:-direct} @@ -41,7 +41,7 @@ RUN chmod 755 /tmp/local/usr/bin/entrypoint \ /go/src/code.gitea.io/gitea/environment-to-ini RUN chmod 644 /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete -FROM docker.io/library/alpine:3.19 +FROM docker.io/library/alpine:3.20 LABEL maintainer="maintainers@gitea.io" EXPOSE 22 3000 diff --git a/Dockerfile.rootless b/Dockerfile.rootless index dd7da97278..b1d2368252 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -1,5 +1,5 @@ # Build stage -FROM docker.io/library/golang:1.22-alpine3.19 AS build-env +FROM docker.io/library/golang:1.22-alpine3.20 AS build-env ARG GOPROXY ENV GOPROXY ${GOPROXY:-direct} @@ -39,7 +39,7 @@ RUN chmod 755 /tmp/local/usr/local/bin/docker-entrypoint.sh \ /go/src/code.gitea.io/gitea/environment-to-ini RUN chmod 644 /go/src/code.gitea.io/gitea/contrib/autocompletion/bash_autocomplete -FROM docker.io/library/alpine:3.19 +FROM docker.io/library/alpine:3.20 LABEL maintainer="maintainers@gitea.io" EXPOSE 2222 3000 diff --git a/Makefile b/Makefile index 0cd6abb81e..80efcbe46d 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ ifneq ($(GITHUB_REF_TYPE),branch) GITEA_VERSION ?= $(VERSION) else ifneq ($(GITHUB_REF_NAME),) - VERSION ?= $(subst release/v,,$(GITHUB_REF_NAME)) + VERSION ?= $(subst release/v,,$(GITHUB_REF_NAME))-nightly else VERSION ?= main endif @@ -778,7 +778,7 @@ generate-backend: $(TAGS_PREREQ) generate-go .PHONY: generate-go generate-go: $(TAGS_PREREQ) @echo "Running go generate..." - @CC= GOOS= GOARCH= $(GO) generate -tags '$(TAGS)' ./... + @CC= GOOS= GOARCH= CGO_ENABLED=0 $(GO) generate -tags '$(TAGS)' ./... .PHONY: security-check security-check: diff --git a/cmd/hook.go b/cmd/hook.go index 2a9c25add5..6e31710caf 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -220,10 +220,7 @@ Gitea or set your environment appropriately.`, "") } } - supportProcReceive := false - if git.CheckGitVersionAtLeast("2.29") == nil { - supportProcReceive = true - } + supportProcReceive := git.DefaultFeatures().SupportProcReceive for scanner.Scan() { // TODO: support news feeds for wiki @@ -341,6 +338,7 @@ Gitea or set your environment appropriately.`, "") isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) repoName := os.Getenv(repo_module.EnvRepoName) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) + prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) pusherName := os.Getenv(repo_module.EnvPusherName) hookOptions := private.HookOptions{ @@ -350,6 +348,8 @@ Gitea or set your environment appropriately.`, "") GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitPushOptions: pushOptions(), + PullRequestID: prID, + PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)), } oldCommitIDs := make([]string, hookBatchSize) newCommitIDs := make([]string, hookBatchSize) @@ -497,7 +497,7 @@ Gitea or set your environment appropriately.`, "") return nil } - if git.CheckGitVersionAtLeast("2.29") != nil { + if !git.DefaultFeatures().SupportProcReceive { return fail(ctx, "No proc-receive support", "current git version doesn't support proc-receive.") } diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go index aa49445a89..357416fc33 100644 --- a/cmd/migrate_storage.go +++ b/cmd/migrate_storage.go @@ -34,7 +34,7 @@ var CmdMigrateStorage = &cli.Command{ Name: "type", Aliases: []string{"t"}, Value: "", - Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log'", + Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts", }, &cli.StringFlag{ Name: "storage", @@ -160,6 +160,13 @@ func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) er }) } +func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error { + return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error { + _, err := storage.Copy(dstStorage, artifact.ArtifactPath, storage.ActionsArtifacts, artifact.ArtifactPath) + return err + }) +} + func runMigrateStorage(ctx *cli.Context) error { stdCtx, cancel := installSignals() defer cancel() @@ -223,13 +230,14 @@ func runMigrateStorage(ctx *cli.Context) error { } migratedMethods := map[string]func(context.Context, storage.ObjectStorage) error{ - "attachments": migrateAttachments, - "lfs": migrateLFS, - "avatars": migrateAvatars, - "repo-avatars": migrateRepoAvatars, - "repo-archivers": migrateRepoArchivers, - "packages": migratePackages, - "actions-log": migrateActionsLog, + "attachments": migrateAttachments, + "lfs": migrateLFS, + "avatars": migrateAvatars, + "repo-avatars": migrateRepoAvatars, + "repo-archivers": migrateRepoArchivers, + "packages": migratePackages, + "actions-log": migrateActionsLog, + "actions-artifacts": migrateActionsArtifacts, } tp := strings.ToLower(ctx.String("type")) diff --git a/cmd/serv.go b/cmd/serv.go index 90190a19db..2bfd111061 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -178,7 +178,7 @@ func runServ(c *cli.Context) error { } if len(words) < 2 { - if git.CheckGitVersionAtLeast("2.29") == nil { + if git.DefaultFeatures().SupportProcReceive { // for AGit Flow if cmd == "ssh_info" { fmt.Print(`{"type":"gitea","version":1}`) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 62db26fb02..4e896c7baa 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2035,6 +2035,17 @@ LEVEL = Info ;; or only create new users if UPDATE_EXISTING is set to false ;UPDATE_EXISTING = true +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Cleanup expired actions assets +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[cron.cleanup_actions] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;ENABLED = true +;RUN_AT_START = true +;SCHEDULE = @midnight + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Clean-up deleted branches diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 7bf23c9b99..628942e841 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -214,9 +214,9 @@ The following configuration set `Content-Type: application/vnd.android.package-a - `SITEMAP_PAGING_NUM`: **20**: Number of items that are displayed in a single subsitemap. - `GRAPH_MAX_COMMIT_NUM`: **100**: Number of maximum commits shown in the commit graph. - `CODE_COMMENT_LINES`: **4**: Number of line of codes shown for a code comment. -- `DEFAULT_THEME`: **gitea-auto**: Set the default theme for the Gitea installation, custom themes could be provided by "{CustomPath}/public/assets/css/theme-*.css". +- `DEFAULT_THEME`: **gitea-auto**: Set the default theme for the Gitea installation, custom themes could be provided by `{CustomPath}/public/assets/css/theme-*.css`. - `SHOW_USER_EMAIL`: **true**: Whether the email of the user should be shown in the Explore Users page. -- `THEMES`: **_empty_**: All available themes by "{CustomPath}/public/assets/css/theme-*.css". Allow users select personalized themes. +- `THEMES`: **_empty_**: All available themes by `{CustomPath}/public/assets/css/theme-*.css`. Allow users select personalized themes. - `MAX_DISPLAY_FILE_SIZE`: **8388608**: Max size of files to be displayed (default is 8MiB) - `AMBIGUOUS_UNICODE_DETECTION`: **true**: Detect ambiguous unicode characters in file contents and show warnings on the UI - `REACTIONS`: All available reactions users can choose on issues/prs and comments @@ -974,12 +974,20 @@ Default templates for project boards: - `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts. - `UPDATE_EXISTING`: **true**: Create new users, update existing user data and disable users that are not in external source anymore (default) or only create new users if UPDATE_EXISTING is set to false. -## Cron - Cleanup Expired Actions Assets (`cron.cleanup_actions`) +#### Cron - Cleanup Expired Actions Assets (`cron.cleanup_actions`) - `ENABLED`: **true**: Enable cleanup expired actions assets job. - `RUN_AT_START`: **true**: Run job at start time (if ENABLED). - `SCHEDULE`: **@midnight** : Cron syntax for the job. +#### Cron - Cleanup Deleted Branches (`cron.deleted_branches_cleanup`) + +- `ENABLED`: **true**: Enable deleted branches cleanup. +- `RUN_AT_START`: **true**: Run job at start time (if ENABLED). +- `NOTICE_ON_SUCCESS`: **false**: Set to true to log a success message. +- `SCHEDULE`: **@midnight**: Cron syntax for scheduling deleted branches cleanup. +- `OLDER_THAN`: **24h**: Branches deleted OLDER_THAN ago will be cleaned up. + ### Extended cron tasks (not enabled by default) #### Cron - Garbage collect all repositories (`cron.git_gc_repos`) diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md index 0d08a5e51b..3bb31d3d71 100644 --- a/docs/content/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/administration/config-cheat-sheet.zh-cn.md @@ -212,9 +212,9 @@ menu: - `SITEMAP_PAGING_NUM`: **20**: 在单个子SiteMap中显示的项数。 - `GRAPH_MAX_COMMIT_NUM`: **100**: 提交图中显示的最大commit数量。 - `CODE_COMMENT_LINES`: **4**: 在代码评论中能够显示的最大代码行数。 -- `DEFAULT_THEME`: **gitea-auto**: 在Gitea安装时候设置的默认主题,自定义的主题可以通过 "{CustomPath}/public/assets/css/theme-*.css" 提供。 +- `DEFAULT_THEME`: **gitea-auto**: 在Gitea安装时候设置的默认主题,自定义的主题可以通过 `{CustomPath}/public/assets/css/theme-*.css` 提供。 - `SHOW_USER_EMAIL`: **true**: 用户的电子邮件是否应该显示在`Explore Users`页面中。 -- `THEMES`: **_empty_**: 所有可用的主题(由 "{CustomPath}/public/assets/css/theme-*.css" 提供)。允许用户选择个性化的主题, +- `THEMES`: **_empty_**: 所有可用的主题(由 `{CustomPath}/public/assets/css/theme-*.css` 提供)。允许用户选择个性化的主题, - `MAX_DISPLAY_FILE_SIZE`: **8388608**: 能够显示文件的最大大小(默认为8MiB)。 - `REACTIONS`: 用户可以在问题(Issue)、Pull Request(PR)以及评论中选择的所有可选的反应。 这些值可以是表情符号别名(例如::smile:)或Unicode表情符号。 diff --git a/docs/content/administration/reverse-proxies.en-us.md b/docs/content/administration/reverse-proxies.en-us.md index fe54c67d02..5fbd0eb0b7 100644 --- a/docs/content/administration/reverse-proxies.en-us.md +++ b/docs/content/administration/reverse-proxies.en-us.md @@ -17,15 +17,35 @@ menu: # Reverse Proxies +## General configuration + +1. Set `[server] ROOT_URL = https://git.example.com/` in your `app.ini` file. +2. Make the reverse-proxy pass `https://git.example.com/foo` to `http://gitea:3000/foo`. +3. Make sure the reverse-proxy does not decode the URI. The request `https://git.example.com/a%2Fb` should be passed as `http://gitea:3000/a%2Fb`. +4. Make sure `Host` and `X-Fowarded-Proto` headers are correctly passed to Gitea to make Gitea see the real URL being visited. + +### Use a sub-path + +Usually it's **not recommended** to put Gitea in a sub-path, it's not widely used and may have some issues in rare cases. + +To make Gitea work with a sub-path (eg: `https://common.example.com/gitea/`), +there are some extra requirements besides the general configuration above: + +1. Use `[server] ROOT_URL = https://common.example.com/gitea/` in your `app.ini` file. +2. Make the reverse-proxy pass `https://common.example.com/gitea/foo` to `http://gitea:3000/foo`. +3. The container registry requires a fixed sub-path `/v2` at the root level which must be configured: + - Make the reverse-proxy pass `https://common.example.com/v2` to `http://gitea:3000/v2`. + - Make sure the URI and headers are also correctly passed (see the general configuration above). + ## Nginx -If you want Nginx to serve your Gitea instance, add the following `server` section to the `http` section of `nginx.conf`: +If you want Nginx to serve your Gitea instance, add the following `server` section to the `http` section of `nginx.conf`. -``` +Make sure `client_max_body_size` is large enough, otherwise there would be "413 Request Entity Too Large" error when uploading large files. + +```nginx server { - listen 80; - server_name git.example.com; - + ... location / { client_max_body_size 512M; proxy_pass http://localhost:3000; @@ -39,37 +59,35 @@ server { } ``` -### Resolving Error: 413 Request Entity Too Large - -This error indicates nginx is configured to restrict the file upload size, -it affects attachment uploading, form posting, package uploading and LFS pushing, etc. -You can fine tune the `client_max_body_size` option according to [nginx document](http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size). - ## Nginx with a sub-path -In case you already have a site, and you want Gitea to share the domain name, you can setup Nginx to serve Gitea under a sub-path by adding the following `server` section inside the `http` section of `nginx.conf`: +In case you already have a site, and you want Gitea to share the domain name, +you can setup Nginx to serve Gitea under a sub-path by adding the following `server` section +into the `http` section of `nginx.conf`: -``` +```nginx server { - listen 80; - server_name git.example.com; - - # Note: Trailing slash - location /gitea/ { + ... + location ~ ^/(gitea|v2)($|/) { client_max_body_size 512M; - # make nginx use unescaped URI, keep "%2F" as is + # make nginx use unescaped URI, keep "%2F" as-is, remove the "/gitea" sub-path prefix, pass "/v2" as-is. rewrite ^ $request_uri; - rewrite ^/gitea(/.*) $1 break; + rewrite ^(/gitea)?(/.*) $2 break; proxy_pass http://127.0.0.1:3000$uri; # other common HTTP headers, see the "Nginx" config section above - proxy_set_header ... + proxy_set_header Connection $http_connection; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` -Then you **MUST** set something like `[server] ROOT_URL = http://git.example.com/git/` correctly in your configuration. +Then you **MUST** set something like `[server] ROOT_URL = http://git.example.com/gitea/` correctly in your configuration. ## Nginx and serve static resources directly @@ -93,7 +111,7 @@ or use a cdn for the static files. Set `[server] STATIC_URL_PREFIX = /_/static` in your configuration. -```apacheconf +```nginx server { listen 80; server_name git.example.com; @@ -112,7 +130,7 @@ server { Set `[server] STATIC_URL_PREFIX = http://cdn.example.com/gitea` in your configuration. -```apacheconf +```nginx # application server running Gitea server { listen 80; @@ -124,7 +142,7 @@ server { } ``` -```apacheconf +```nginx # static content delivery server server { listen 80; @@ -151,6 +169,8 @@ If you want Apache HTTPD to serve your Gitea instance, you can add the following ProxyRequests off AllowEncodedSlashes NoDecode ProxyPass / http://localhost:3000/ nocanon + ProxyPreserveHost On + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} ``` @@ -172,6 +192,8 @@ In case you already have a site, and you want Gitea to share the domain name, yo AllowEncodedSlashes NoDecode # Note: no trailing slash after either /git or port ProxyPass /git http://localhost:3000 nocanon + ProxyPreserveHost On + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} ``` @@ -183,7 +205,7 @@ Note: The following Apache HTTPD mods must be enabled: `proxy`, `proxy_http`. If you want Caddy to serve your Gitea instance, you can add the following server block to your Caddyfile: -```apacheconf +``` git.example.com { reverse_proxy localhost:3000 } @@ -193,7 +215,7 @@ git.example.com { In case you already have a site, and you want Gitea to share the domain name, you can setup Caddy to serve Gitea under a sub-path by adding the following to your server block in your Caddyfile: -```apacheconf +``` git.example.com { route /git/* { uri strip_prefix /git @@ -371,19 +393,3 @@ gitea: This config assumes that you are handling HTTPS on the traefik side and using HTTP between Gitea and traefik. Then you **MUST** set something like `[server] ROOT_URL = http://example.com/gitea/` correctly in your configuration. - -## General sub-path configuration - -Usually it's not recommended to put Gitea in a sub-path, it's not widely used and may have some issues in rare cases. - -If you really need to do so, to make Gitea works with sub-path (eg: `http://example.com/gitea/`), here are the requirements: - -1. Set `[server] ROOT_URL = http://example.com/gitea/` in your `app.ini` file. -2. Make the reverse-proxy pass `http://example.com/gitea/foo` to `http://gitea-server:3000/foo`. -3. Make sure the reverse-proxy not decode the URI, the request `http://example.com/gitea/a%2Fb` should be passed as `http://gitea-server:3000/a%2Fb`. - -## Docker / Container Registry - -The container registry uses a fixed sub-path `/v2` which can't be changed. -Even if you deploy Gitea with a different sub-path, `/v2` will be used by the `docker` client. -Therefore you may need to add an additional route to your reverse proxy configuration. diff --git a/docs/content/usage/actions/comparison.en-us.md b/docs/content/usage/actions/comparison.en-us.md index 1ea3afac5b..5b084e09c4 100644 --- a/docs/content/usage/actions/comparison.en-us.md +++ b/docs/content/usage/actions/comparison.en-us.md @@ -108,6 +108,10 @@ See [Creating an annotation for an error](https://docs.github.com/en/actions/usi It's ignored by Gitea Actions now. +### Expressions + +For [expressions](https://docs.github.com/en/actions/learn-github-actions/expressions), only [`always()`](https://docs.github.com/en/actions/learn-github-actions/expressions#always) is supported. + ## Missing UI features ### Pre and Post steps diff --git a/docs/content/usage/actions/comparison.zh-cn.md b/docs/content/usage/actions/comparison.zh-cn.md index 16b2181ba2..79450e8eab 100644 --- a/docs/content/usage/actions/comparison.zh-cn.md +++ b/docs/content/usage/actions/comparison.zh-cn.md @@ -108,6 +108,10 @@ Gitea Actions目前不支持此功能。 Gitea Actions目前不支持此功能。 +### 表达式 + +对于 [表达式](https://docs.github.com/en/actions/learn-github-actions/expressions), 当前仅 [`always()`](https://docs.github.com/en/actions/learn-github-actions/expressions#always) 被支持。 + ## 缺失的UI功能 ### 预处理和后处理步骤 diff --git a/docs/content/usage/packages/npm.en-us.md b/docs/content/usage/packages/npm.en-us.md index 1590b9623a..ccc075b140 100644 --- a/docs/content/usage/packages/npm.en-us.md +++ b/docs/content/usage/packages/npm.en-us.md @@ -30,7 +30,7 @@ The following examples use the `npm` tool with the scope `@test`. To register the package registry you need to configure a new package source. ```shell -npm config set {scope}:registry https://gitea.example.com/api/packages/{owner}/npm/ +npm config set {scope}:registry=https://gitea.example.com/api/packages/{owner}/npm/ npm config set -- '//gitea.example.com/api/packages/{owner}/npm/:_authToken' "{token}" ``` @@ -43,7 +43,7 @@ npm config set -- '//gitea.example.com/api/packages/{owner}/npm/:_authToken' "{t For example: ```shell -npm config set @test:registry https://gitea.example.com/api/packages/testuser/npm/ +npm config set @test:registry=https://gitea.example.com/api/packages/testuser/npm/ npm config set -- '//gitea.example.com/api/packages/testuser/npm/:_authToken' "personal_access_token" ``` diff --git a/docs/content/usage/packages/npm.zh-cn.md b/docs/content/usage/packages/npm.zh-cn.md index d51b8b78a1..772cdc08b2 100644 --- a/docs/content/usage/packages/npm.zh-cn.md +++ b/docs/content/usage/packages/npm.zh-cn.md @@ -30,7 +30,7 @@ menu: 要注册软件包注册表,您需要配置一个新的软件包源。 ```shell -npm config set {scope}:registry https://gitea.example.com/api/packages/{owner}/npm/ +npm config set {scope}:registry=https://gitea.example.com/api/packages/{owner}/npm/ npm config set -- '//gitea.example.com/api/packages/{owner}/npm/:_authToken' "{token}" ``` @@ -43,7 +43,7 @@ npm config set -- '//gitea.example.com/api/packages/{owner}/npm/:_authToken' "{t 例如: ```shell -npm config set @test:registry https://gitea.example.com/api/packages/testuser/npm/ +npm config set @test:registry=https://gitea.example.com/api/packages/testuser/npm/ npm config set -- '//gitea.example.com/api/packages/testuser/npm/:_authToken' "personal_access_token" ``` diff --git a/docs/content/usage/repo-mirror.en-us.md b/docs/content/usage/repo-mirror.en-us.md index 8804a8885a..9d23159890 100644 --- a/docs/content/usage/repo-mirror.en-us.md +++ b/docs/content/usage/repo-mirror.en-us.md @@ -58,7 +58,7 @@ The repository now gets mirrored periodically to the remote repository. You can To set up a mirror from Gitea to GitHub, you need to follow these steps: -1. Create a [GitHub personal access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with the *public_repo* box checked. Also check the **workflow** checkbox in case your repo using act for continuous integration. +1. Create a [GitHub personal access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with the *public_repo* box checked. Also check the **workflow** checkbox in case your repo uses GitHub Actions for continuous integration. 2. Create a repository with that name on GitHub. Unlike Gitea, GitHub does not support creating repositories by pushing to the remote. You can also use an existing remote repo if it has the same commit history as your Gitea repo. 3. In the settings of your Gitea repo, fill in the **Git Remote Repository URL**: `https://github.com//.git`. 4. Fill in the **Authorization** fields with your GitHub username and the personal access token as **Password**. @@ -91,10 +91,10 @@ The repository pushes shortly thereafter. To force a push, select the **Synchron ### Mirror an existing ssh repository -Currently gitea supports no ssh push mirrors. You can work around this by adding a `post-receive` hook to your gitea repository that pushes manually. +Currently Gitea supports no ssh push mirrors. You can work around this by adding a `post-receive` hook to your Gitea repository that pushes manually. -1. Make sure the user running gitea has access to the git repo you are trying to mirror to from shell. -2. On the Webinterface at the repository settings > git hooks add a post-receive hook for the mirror. I.e. +1. Make sure the user running Gitea has access to the git repo you are trying to mirror to from shell. +2. On the web interface at the repository settings > git hooks add a post-receive hook for the mirror. I.e. ``` #!/usr/bin/env bash diff --git a/go.mod b/go.mod index 1e88de3011..3f780c3903 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( code.gitea.io/sdk/gitea v0.17.1 codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 connectrpc.com/connect v1.15.0 - gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028 + gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed gitea.com/go-chi/cache v0.2.0 gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 diff --git a/go.sum b/go.sum index cbf397b95c..5bf8d737e4 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4H git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= gitea.com/gitea/act v0.259.1 h1:8GG1o/xtUHl3qjn5f0h/2FXrT5ubBn05TJOM5ry+FBw= gitea.com/gitea/act v0.259.1/go.mod h1:UxZWRYqQG2Yj4+4OqfGWW5a3HELwejyWFQyU7F1jUD8= -gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028 h1:6/QAx4+s0dyRwdaTFPTnhGppuiuu0OqxIH9szyTpvKw= -gitea.com/go-chi/binding v0.0.0-20240316035258-17450c5f3028/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= +gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= +gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= gitea.com/go-chi/cache v0.2.0 h1:E0npuTfDW6CT1yD8NMDVc1SK6IeRjfmRL2zlEsCEd7w= gitea.com/go-chi/cache v0.2.0/go.mod h1:iQlVK2aKTZ/rE9UcHyz9pQWGvdP9i1eI2spOpzgCrtE= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo= diff --git a/models/activities/action.go b/models/activities/action.go index 7e2ef4c9ae..d23f2bd986 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -524,7 +524,12 @@ func activityQueryCondition(ctx context.Context, opts GetFeedsOptions) (builder. } if opts.RequestedRepo != nil { - cond = cond.And(builder.Eq{"repo_id": opts.RequestedRepo.ID}) + // repo's actions could have duplicate items, see the comment of NotifyWatchers + // so here we only filter the "original items", aka: user_id == act_user_id + cond = cond.And( + builder.Eq{"`action`.repo_id": opts.RequestedRepo.ID}, + builder.Expr("`action`.user_id = `action`.act_user_id"), + ) } if opts.RequestedTeam != nil { @@ -577,6 +582,10 @@ func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error) } // NotifyWatchers creates batch of actions for every watcher. +// It could insert duplicate actions for a repository action, like this: +// * Original action: UserID=1 (the real actor), ActUserID=1 +// * Organization action: UserID=100 (the repo's org), ActUserID=1 +// * Watcher action: UserID=20 (a user who is watching a repo), ActUserID=1 func NotifyWatchers(ctx context.Context, actions ...*Action) error { var watchers []*repo_model.Watch var repo *repo_model.Repository diff --git a/models/activities/action_test.go b/models/activities/action_test.go index 5467bd35fb..557415dcda 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -318,3 +318,24 @@ func TestDeleteIssueActions(t *testing.T) { assert.NoError(t, activities_model.DeleteIssueActions(db.DefaultContext, issue.RepoID, issue.ID, issue.Index)) unittest.AssertCount(t, &activities_model.Action{}, 0) } + +func TestRepoActions(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + _ = db.TruncateBeans(db.DefaultContext, &activities_model.Action{}) + for i := 0; i < 3; i++ { + _ = db.Insert(db.DefaultContext, &activities_model.Action{ + UserID: 2 + int64(i), + ActUserID: 2, + RepoID: repo.ID, + OpType: activities_model.ActionCommentIssue, + }) + } + count, _ := db.Count[activities_model.Action](db.DefaultContext, &db.ListOptions{}) + assert.EqualValues(t, 3, count) + actions, _, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{ + RequestedRepo: repo, + }) + assert.NoError(t, err) + assert.Len(t, actions, 1) +} diff --git a/models/db/engine.go b/models/db/engine.go index 25f4066ea1..847ba58c26 100755 --- a/models/db/engine.go +++ b/models/db/engine.go @@ -57,6 +57,7 @@ type Engine interface { SumInt(bean any, columnName string) (res int64, err error) Sync(...any) error Select(string) *xorm.Session + SetExpr(string, any) *xorm.Session NotIn(string, ...any) *xorm.Session OrderBy(any, ...any) *xorm.Session Exist(...any) (bool, error) diff --git a/models/fixtures/branch.yml b/models/fixtures/branch.yml index 93003049c6..c7bdff7733 100644 --- a/models/fixtures/branch.yml +++ b/models/fixtures/branch.yml @@ -45,3 +45,39 @@ is_deleted: false deleted_by_id: 0 deleted_unix: 0 + +- + id: 5 + repo_id: 10 + name: 'master' + commit_id: '65f1bf27bc3bf70f64657658635e66094edbcb4d' + commit_message: 'Initial commit' + commit_time: 1489927679 + pusher_id: 12 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 + +- + id: 6 + repo_id: 10 + name: 'outdated-new-branch' + commit_id: 'cb24c347e328d83c1e0c3c908a6b2c0a2fcb8a3d' + commit_message: 'add' + commit_time: 1489927679 + pusher_id: 12 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 + +- + id: 14 + repo_id: 11 + name: 'master' + commit_id: '65f1bf27bc3bf70f64657658635e66094edbcb4d' + commit_message: 'Initial commit' + commit_time: 1489927679 + pusher_id: 13 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 diff --git a/models/fixtures/issue_index.yml b/models/fixtures/issue_index.yml index de6e955804..5aabc08e38 100644 --- a/models/fixtures/issue_index.yml +++ b/models/fixtures/issue_index.yml @@ -1,27 +1,35 @@ - group_id: 1 max_index: 5 + - group_id: 2 max_index: 2 + - group_id: 3 max_index: 2 + - group_id: 10 max_index: 1 + - group_id: 32 max_index: 2 + - group_id: 48 max_index: 1 + - group_id: 42 max_index: 1 + - group_id: 50 max_index: 1 + - group_id: 51 max_index: 1 diff --git a/models/fixtures/org_user.yml b/models/fixtures/org_user.yml index a7fbcb2c5a..cf21b84aa9 100644 --- a/models/fixtures/org_user.yml +++ b/models/fixtures/org_user.yml @@ -117,3 +117,15 @@ uid: 40 org_id: 41 is_public: true + +- + id: 21 + uid: 12 + org_id: 25 + is_public: true + +- + id: 22 + uid: 2 + org_id: 35 + is_public: true diff --git a/models/fixtures/protected_tag.yml b/models/fixtures/protected_tag.yml new file mode 100644 index 0000000000..dbec52c0c2 --- /dev/null +++ b/models/fixtures/protected_tag.yml @@ -0,0 +1,24 @@ +- + id: 1 + repo_id: 4 + name_pattern: /v.+/ + allowlist_user_i_ds: [] + allowlist_team_i_ds: [] + created_unix: 1715596037 + updated_unix: 1715596037 +- + id: 2 + repo_id: 1 + name_pattern: v-* + allowlist_user_i_ds: [] + allowlist_team_i_ds: [] + created_unix: 1715596037 + updated_unix: 1715596037 +- + id: 3 + repo_id: 1 + name_pattern: v-1.1 + allowlist_user_i_ds: [2] + allowlist_team_i_ds: [] + created_unix: 1715596037 + updated_unix: 1715596037 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index e5c6224c96..e1f1dd7367 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -327,7 +327,7 @@ is_archived: false is_mirror: false status: 0 - is_fork: false + is_fork: true fork_id: 10 is_template: false template_id: 0 diff --git a/models/fixtures/team.yml b/models/fixtures/team.yml index 149fe90888..b549d0589b 100644 --- a/models/fixtures/team.yml +++ b/models/fixtures/team.yml @@ -239,3 +239,25 @@ num_members: 2 includes_all_repositories: false can_create_org_repo: false + +- + id: 23 + org_id: 25 + lower_name: owners + name: Owners + authorize: 4 # owner + num_repos: 0 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: true + +- + id: 24 + org_id: 35 + lower_name: team24 + name: team24 + authorize: 2 # write + num_repos: 0 + num_members: 1 + includes_all_repositories: true + can_create_org_repo: false diff --git a/models/fixtures/team_unit.yml b/models/fixtures/team_unit.yml index de0e8d738b..110019eee3 100644 --- a/models/fixtures/team_unit.yml +++ b/models/fixtures/team_unit.yml @@ -322,3 +322,21 @@ team_id: 22 type: 3 access_mode: 1 + +- + id: 55 + team_id: 18 + type: 1 # code + access_mode: 4 + +- + id: 56 + team_id: 23 + type: 1 # code + access_mode: 4 + +- + id: 57 + team_id: 24 + type: 1 # code + access_mode: 2 diff --git a/models/fixtures/team_user.yml b/models/fixtures/team_user.yml index 02d57ae644..6b2d153278 100644 --- a/models/fixtures/team_user.yml +++ b/models/fixtures/team_user.yml @@ -147,3 +147,15 @@ org_id: 41 team_id: 22 uid: 39 + +- + id: 26 + org_id: 25 + team_id: 23 + uid: 12 + +- + id: 27 + org_id: 35 + team_id: 24 + uid: 2 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index a3de535508..8504d88ce5 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -918,8 +918,8 @@ num_following: 0 num_stars: 0 num_repos: 0 - num_teams: 1 - num_members: 1 + num_teams: 2 + num_members: 2 visibility: 0 repo_admin_change_team_access: false theme: "" @@ -1289,8 +1289,8 @@ num_following: 0 num_stars: 0 num_repos: 0 - num_teams: 1 - num_members: 1 + num_teams: 2 + num_members: 2 visibility: 2 repo_admin_change_team_access: false theme: "" diff --git a/models/git/branch.go b/models/git/branch.go index 2979dff3d2..c315d921ff 100644 --- a/models/git/branch.go +++ b/models/git/branch.go @@ -10,9 +10,11 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -102,8 +104,9 @@ func (err ErrBranchesEqual) Unwrap() error { // for pagination, keyword search and filtering type Branch struct { ID int64 - RepoID int64 `xorm:"UNIQUE(s)"` - Name string `xorm:"UNIQUE(s) NOT NULL"` // git's ref-name is case-sensitive internally, however, in some databases (mssql, mysql, by default), it's case-insensitive at the moment + RepoID int64 `xorm:"UNIQUE(s)"` + Repo *repo_model.Repository `xorm:"-"` + Name string `xorm:"UNIQUE(s) NOT NULL"` // git's ref-name is case-sensitive internally, however, in some databases (mssql, mysql, by default), it's case-insensitive at the moment CommitID string CommitMessage string `xorm:"TEXT"` // it only stores the message summary (the first line) PusherID int64 @@ -139,6 +142,14 @@ func (b *Branch) LoadPusher(ctx context.Context) (err error) { return err } +func (b *Branch) LoadRepo(ctx context.Context) (err error) { + if b.Repo != nil || b.RepoID == 0 { + return nil + } + b.Repo, err = repo_model.GetRepositoryByID(ctx, b.RepoID) + return err +} + func init() { db.RegisterModel(new(Branch)) db.RegisterModel(new(RenamedBranch)) @@ -400,24 +411,111 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str return committer.Commit() } -// FindRecentlyPushedNewBranches return at most 2 new branches pushed by the user in 6 hours which has no opened PRs created -// except the indicate branch -func FindRecentlyPushedNewBranches(ctx context.Context, repoID, userID int64, excludeBranchName string) (BranchList, error) { - branches := make(BranchList, 0, 2) - subQuery := builder.Select("head_branch").From("pull_request"). - InnerJoin("issue", "issue.id = pull_request.issue_id"). - Where(builder.Eq{ - "pull_request.head_repo_id": repoID, - "issue.is_closed": false, - }) - err := db.GetEngine(ctx). - Where("pusher_id=? AND is_deleted=?", userID, false). - And("name <> ?", excludeBranchName). - And("repo_id = ?", repoID). - And("commit_time >= ?", time.Now().Add(-time.Hour*6).Unix()). - NotIn("name", subQuery). - OrderBy("branch.commit_time DESC"). - Limit(2). - Find(&branches) - return branches, err +type FindRecentlyPushedNewBranchesOptions struct { + Repo *repo_model.Repository + BaseRepo *repo_model.Repository + CommitAfterUnix int64 + MaxCount int +} + +type RecentlyPushedNewBranch struct { + BranchDisplayName string + BranchLink string + BranchCompareURL string + CommitTime timeutil.TimeStamp +} + +// FindRecentlyPushedNewBranches return at most 2 new branches pushed by the user in 2 hours which has no opened PRs created +// if opts.CommitAfterUnix is 0, we will find the branches that were committed to in the last 2 hours +// if opts.ListOptions is not set, we will only display top 2 latest branch +func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, opts *FindRecentlyPushedNewBranchesOptions) ([]*RecentlyPushedNewBranch, error) { + if doer == nil { + return []*RecentlyPushedNewBranch{}, nil + } + + // find all related repo ids + repoOpts := repo_model.SearchRepoOptions{ + Actor: doer, + Private: true, + AllPublic: false, // Include also all public repositories of users and public organisations + AllLimited: false, // Include also all public repositories of limited organisations + Fork: optional.Some(true), + ForkFrom: opts.BaseRepo.ID, + Archived: optional.Some(false), + } + repoCond := repo_model.SearchRepositoryCondition(&repoOpts).And(repo_model.AccessibleRepositoryCondition(doer, unit.TypeCode)) + if opts.Repo.ID == opts.BaseRepo.ID { + // should also include the base repo's branches + repoCond = repoCond.Or(builder.Eq{"id": opts.BaseRepo.ID}) + } else { + // in fork repo, we only detect the fork repo's branch + repoCond = repoCond.And(builder.Eq{"id": opts.Repo.ID}) + } + repoIDs := builder.Select("id").From("repository").Where(repoCond) + + if opts.CommitAfterUnix == 0 { + opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix() + } + + baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) + if err != nil { + return nil, err + } + + // find all related branches, these branches may already created PRs, we will check later + var branches []*Branch + if err := db.GetEngine(ctx). + Where(builder.And( + builder.Eq{ + "pusher_id": doer.ID, + "is_deleted": false, + }, + builder.Gte{"commit_time": opts.CommitAfterUnix}, + builder.In("repo_id", repoIDs), + // newly created branch have no changes, so skip them + builder.Neq{"commit_id": baseBranch.CommitID}, + )). + OrderBy(db.SearchOrderByRecentUpdated.String()). + Find(&branches); err != nil { + return nil, err + } + + newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches)) + if opts.MaxCount == 0 { + // by default we display 2 recently pushed new branch + opts.MaxCount = 2 + } + for _, branch := range branches { + // whether branch have already created PR + count, err := db.GetEngine(ctx).Table("pull_request"). + // we should not only use branch name here, because if there are branches with same name in other repos, + // we can not detect them correctly + Where(builder.Eq{"head_repo_id": branch.RepoID, "head_branch": branch.Name}).Count() + if err != nil { + return nil, err + } + + // if no PR, we add to the result + if count == 0 { + if err := branch.LoadRepo(ctx); err != nil { + return nil, err + } + + branchDisplayName := branch.Name + if branch.Repo.ID != opts.BaseRepo.ID && branch.Repo.ID != opts.Repo.ID { + branchDisplayName = fmt.Sprintf("%s:%s", branch.Repo.FullName(), branchDisplayName) + } + newBranches = append(newBranches, &RecentlyPushedNewBranch{ + BranchDisplayName: branchDisplayName, + BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)), + BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name), + CommitTime: branch.CommitTime, + }) + } + if len(newBranches) == opts.MaxCount { + break + } + } + + return newBranches, nil } diff --git a/models/git/branch_list.go b/models/git/branch_list.go index 980bd7b4c9..5c887461d5 100644 --- a/models/git/branch_list.go +++ b/models/git/branch_list.go @@ -7,6 +7,7 @@ import ( "context" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" @@ -59,6 +60,24 @@ func (branches BranchList) LoadPusher(ctx context.Context) error { return nil } +func (branches BranchList) LoadRepo(ctx context.Context) error { + ids := container.FilterSlice(branches, func(branch *Branch) (int64, bool) { + return branch.RepoID, branch.RepoID > 0 && branch.Repo == nil + }) + + reposMap := make(map[int64]*repo_model.Repository, len(ids)) + if err := db.GetEngine(ctx).In("id", ids).Find(&reposMap); err != nil { + return err + } + for _, branch := range branches { + if branch.RepoID <= 0 || branch.Repo != nil { + continue + } + branch.Repo = reposMap[branch.RepoID] + } + return nil +} + type FindBranchOptions struct { db.ListOptions RepoID int64 diff --git a/models/git/commit_status.go b/models/git/commit_status.go index c3cda7b73d..d12afc42c5 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -397,36 +397,16 @@ func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, co // FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) { - type result struct { - Index int64 - SHA string - } - getBase := func() *xorm.Session { - return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID) - } - start := timeutil.TimeStampNow().AddDuration(-before) - results := make([]result, 0, 10) - sess := getBase().And("updated_unix >= ?", start). - Select("max( `index` ) as `index`, sha"). - GroupBy("context_hash, sha").OrderBy("max( `index` ) desc") - - err := sess.Find(&results) - if err != nil { + var contexts []string + if err := db.GetEngine(ctx).Table("commit_status"). + Where("repo_id = ?", repoID).And("updated_unix >= ?", start). + Cols("context").Distinct().Find(&contexts); err != nil { return nil, err } - contexts := make([]string, 0, len(results)) - if len(results) == 0 { - return contexts, nil - } - - conds := make([]builder.Cond, 0, len(results)) - for _, result := range results { - conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA}) - } - return contexts, getBase().And(builder.Or(conds...)).Select("context").Find(&contexts) + return contexts, nil } // NewCommitStatusOptions holds options for creating a CommitStatus diff --git a/models/git/commit_status_test.go b/models/git/commit_status_test.go index 74ba4a1006..08eba6e293 100644 --- a/models/git/commit_status_test.go +++ b/models/git/commit_status_test.go @@ -5,11 +5,15 @@ package git_test import ( "testing" + "time" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/structs" "github.com/stretchr/testify/assert" @@ -175,3 +179,55 @@ func Test_CalcCommitStatus(t *testing.T) { assert.Equal(t, kase.expected, git_model.CalcCommitStatus(kase.statuses)) } } + +func TestFindRepoRecentCommitStatusContexts(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo2) + assert.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(repo2.DefaultBranch) + assert.NoError(t, err) + + defer func() { + _, err := db.DeleteByBean(db.DefaultContext, &git_model.CommitStatus{ + RepoID: repo2.ID, + CreatorID: user2.ID, + SHA: commit.ID.String(), + }) + assert.NoError(t, err) + }() + + err = git_model.NewCommitStatus(db.DefaultContext, git_model.NewCommitStatusOptions{ + Repo: repo2, + Creator: user2, + SHA: commit.ID, + CommitStatus: &git_model.CommitStatus{ + State: structs.CommitStatusFailure, + TargetURL: "https://example.com/tests/", + Context: "compliance/lint-backend", + }, + }) + assert.NoError(t, err) + + err = git_model.NewCommitStatus(db.DefaultContext, git_model.NewCommitStatusOptions{ + Repo: repo2, + Creator: user2, + SHA: commit.ID, + CommitStatus: &git_model.CommitStatus{ + State: structs.CommitStatusSuccess, + TargetURL: "https://example.com/tests/", + Context: "compliance/lint-backend", + }, + }) + assert.NoError(t, err) + + contexts, err := git_model.FindRepoRecentCommitStatusContexts(db.DefaultContext, repo2.ID, time.Hour) + assert.NoError(t, err) + if assert.Len(t, contexts, 1) { + assert.Equal(t, "compliance/lint-backend", contexts[0]) + } +} diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 907a5a17b9..e31d2ef151 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -5,11 +5,11 @@ package issues import ( "context" - "fmt" "code.gitea.io/gitea/models/db" project_model "code.gitea.io/gitea/models/project" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" ) // LoadProject load the project the issue was assigned to @@ -90,58 +90,73 @@ func LoadIssuesFromBoardList(ctx context.Context, bs project_model.BoardList) (m return issuesMap, nil } -// ChangeProjectAssign changes the project associated with an issue -func ChangeProjectAssign(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() +// IssueAssignOrRemoveProject changes the project associated with an issue +// If newProjectID is 0, the issue is removed from the project +func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + oldProjectID := issue.projectID(ctx) - if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil { - return err - } - - return committer.Commit() -} - -func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { - oldProjectID := issue.projectID(ctx) - - if err := issue.LoadRepo(ctx); err != nil { - return err - } - - // Only check if we add a new project and not remove it. - if newProjectID > 0 { - newProject, err := project_model.GetProjectByID(ctx, newProjectID) - if err != nil { + if err := issue.LoadRepo(ctx); err != nil { return err } - if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID { - return fmt.Errorf("issue's repository is not the same as project's repository") + + // Only check if we add a new project and not remove it. + if newProjectID > 0 { + newProject, err := project_model.GetProjectByID(ctx, newProjectID) + if err != nil { + return err + } + if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) { + return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID) + } + if newColumnID == 0 { + newDefaultColumn, err := newProject.GetDefaultBoard(ctx) + if err != nil { + return err + } + newColumnID = newDefaultColumn.ID + } } - } - if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { - return err - } - - if oldProjectID > 0 || newProjectID > 0 { - if _, err := CreateComment(ctx, &CreateCommentOptions{ - Type: CommentTypeProject, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - OldProjectID: oldProjectID, - ProjectID: newProjectID, - }); err != nil { + if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { return err } - } - return db.Insert(ctx, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: newProjectID, + if oldProjectID > 0 || newProjectID > 0 { + if _, err := CreateComment(ctx, &CreateCommentOptions{ + Type: CommentTypeProject, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldProjectID: oldProjectID, + ProjectID: newProjectID, + }); err != nil { + return err + } + } + if newProjectID == 0 { + return nil + } + if newColumnID == 0 { + panic("newColumnID must not be zero") // shouldn't happen + } + + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). + Where("project_id=?", newProjectID). + And("project_board_id=?", newColumnID). + Get(&res); err != nil { + return err + } + newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + return db.Insert(ctx, &project_model.ProjectIssue{ + IssueID: issue.ID, + ProjectID: newProjectID, + ProjectBoardID: newColumnID, + Sorting: newSorting, + }) }) } diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index ef96e1ee50..147b7eb3b9 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -429,62 +429,6 @@ func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_mo return nil } -// UpdateIssueByAPI updates all allowed fields of given issue. -// If the issue status is changed a statusChangeComment is returned -// similarly if the title is changed the titleChanged bool is set to true -func UpdateIssueByAPI(ctx context.Context, issue *Issue, doer *user_model.User) (statusChangeComment *Comment, titleChanged bool, err error) { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return nil, false, err - } - defer committer.Close() - - if err := issue.LoadRepo(ctx); err != nil { - return nil, false, fmt.Errorf("loadRepo: %w", err) - } - - // Reload the issue - currentIssue, err := GetIssueByID(ctx, issue.ID) - if err != nil { - return nil, false, err - } - - if _, err := db.GetEngine(ctx).ID(issue.ID).Cols( - "name", "content", "milestone_id", "priority", - "deadline_unix", "updated_unix", "is_locked"). - Update(issue); err != nil { - return nil, false, err - } - - titleChanged = currentIssue.Title != issue.Title - if titleChanged { - opts := &CreateCommentOptions{ - Type: CommentTypeChangeTitle, - Doer: doer, - Repo: issue.Repo, - Issue: issue, - OldTitle: currentIssue.Title, - NewTitle: issue.Title, - } - _, err := CreateComment(ctx, opts) - if err != nil { - return nil, false, fmt.Errorf("createComment: %w", err) - } - } - - if currentIssue.IsClosed != issue.IsClosed { - statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false) - if err != nil { - return nil, false, err - } - } - - if err := issue.AddCrossReferences(ctx, doer, true); err != nil { - return nil, false, err - } - return statusChangeComment, titleChanged, committer.Commit() -} - // UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it. func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) { // if the deadline hasn't changed do nothing diff --git a/models/issues/pull.go b/models/issues/pull.go index 4194df2e3d..014fcd9fd0 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -430,6 +430,21 @@ func (pr *PullRequest) GetGitHeadBranchRefName() string { return fmt.Sprintf("%s%s", git.BranchPrefix, pr.HeadBranch) } +// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) +func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int { + opts := FindCommentsOptions{ + Type: CommentTypeReview, + IssueID: pr.IssueID, + } + conds := opts.ToConds() + + count, err := db.GetEngine(ctx).Where(conds).Count(new(Comment)) + if err != nil { + return 0 + } + return int(count) +} + // IsChecking returns true if this pull request is still checking conflict. func (pr *PullRequest) IsChecking() bool { return pr.Status == PullRequestStatusChecking diff --git a/models/issues/review.go b/models/issues/review.go index 3c6934b060..ca6fd6035b 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -155,14 +155,14 @@ func (r *Review) LoadCodeComments(ctx context.Context) (err error) { if r.CodeComments != nil { return err } - if err = r.loadIssue(ctx); err != nil { + if err = r.LoadIssue(ctx); err != nil { return err } r.CodeComments, err = fetchCodeCommentsByReview(ctx, r.Issue, nil, r, false) return err } -func (r *Review) loadIssue(ctx context.Context) (err error) { +func (r *Review) LoadIssue(ctx context.Context) (err error) { if r.Issue != nil { return err } @@ -199,7 +199,7 @@ func (r *Review) LoadReviewerTeam(ctx context.Context) (err error) { // LoadAttributes loads all attributes except CodeComments func (r *Review) LoadAttributes(ctx context.Context) (err error) { - if err = r.loadIssue(ctx); err != nil { + if err = r.LoadIssue(ctx); err != nil { return err } if err = r.LoadCodeComments(ctx); err != nil { diff --git a/models/migrations/fixtures/Test_RepositoryFormat/review_state.yml b/models/migrations/fixtures/Test_RepositoryFormat/review_state.yml index 1197b086e3..dd64980916 100644 --- a/models/migrations/fixtures/Test_RepositoryFormat/review_state.yml +++ b/models/migrations/fixtures/Test_RepositoryFormat/review_state.yml @@ -1,3 +1,5 @@ - id: 1 + user_id: 1 + pull_id: 1 commit_sha: 19fe5caf872476db265596eaac1dc35ad1c6422d diff --git a/models/migrations/v1_22/v286.go b/models/migrations/v1_22/v286.go index f46d494dfe..e11d16f8de 100644 --- a/models/migrations/v1_22/v286.go +++ b/models/migrations/v1_22/v286.go @@ -36,9 +36,9 @@ func expandHashReferencesToSha256(x *xorm.Engine) error { if setting.Database.Type.IsMSSQL() { // drop indexes that need to be re-created afterwards droppedIndexes := []string{ - "DROP INDEX IF EXISTS [IDX_commit_status_context_hash] ON [commit_status]", - "DROP INDEX IF EXISTS [UQE_review_state_pull_commit_user] ON [review_state]", - "DROP INDEX IF EXISTS [UQE_repo_archiver_s] ON [repo_archiver]", + "DROP INDEX [IDX_commit_status_context_hash] ON [commit_status]", + "DROP INDEX [UQE_review_state_pull_commit_user] ON [review_state]", + "DROP INDEX [UQE_repo_archiver_s] ON [repo_archiver]", } for _, s := range droppedIndexes { _, err := db.Exec(s) diff --git a/models/migrations/v1_22/v286_test.go b/models/migrations/v1_22/v286_test.go index 7c353747e3..a19c9396e2 100644 --- a/models/migrations/v1_22/v286_test.go +++ b/models/migrations/v1_22/v286_test.go @@ -19,21 +19,21 @@ func PrepareOldRepository(t *testing.T) (*xorm.Engine, func()) { type CommitStatus struct { ID int64 - ContextHash string + ContextHash string `xorm:"char(40) index"` } type RepoArchiver struct { ID int64 - RepoID int64 - Type int - CommitID string + RepoID int64 `xorm:"index unique(s)"` + Type int `xorm:"unique(s)"` + CommitID string `xorm:"VARCHAR(40) unique(s)"` } type ReviewState struct { ID int64 - CommitSHA string - UserID int64 - PullID int64 + UserID int64 `xorm:"NOT NULL UNIQUE(pull_commit_user)"` + PullID int64 `xorm:"NOT NULL INDEX UNIQUE(pull_commit_user) DEFAULT 0"` + CommitSHA string `xorm:"NOT NULL VARCHAR(40) UNIQUE(pull_commit_user)"` } type Comment struct { diff --git a/models/organization/org_user_test.go b/models/organization/org_user_test.go index 7924517f31..cf7acdf83b 100644 --- a/models/organization/org_user_test.go +++ b/models/organization/org_user_test.go @@ -81,7 +81,7 @@ func TestUserListIsPublicMember(t *testing.T) { {3, map[int64]bool{2: true, 4: false, 28: true}}, {6, map[int64]bool{5: true, 28: true}}, {7, map[int64]bool{5: false}}, - {25, map[int64]bool{24: true}}, + {25, map[int64]bool{12: true, 24: true}}, {22, map[int64]bool{}}, } for _, v := range tt { @@ -108,8 +108,8 @@ func TestUserListIsUserOrgOwner(t *testing.T) { {3, map[int64]bool{2: true, 4: false, 28: false}}, {6, map[int64]bool{5: true, 28: false}}, {7, map[int64]bool{5: true}}, - {25, map[int64]bool{24: false}}, // ErrTeamNotExist - {22, map[int64]bool{}}, // No member + {25, map[int64]bool{12: true, 24: false}}, // ErrTeamNotExist + {22, map[int64]bool{}}, // No member } for _, v := range tt { t.Run(fmt.Sprintf("IsUserOrgOwnerOfOrgId%d", v.orgid), func(t *testing.T) { diff --git a/models/project/board.go b/models/project/board.go index 7faabc52c5..a52baa0c18 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -5,12 +5,14 @@ package project import ( "context" + "errors" "fmt" "regexp" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) @@ -82,6 +84,17 @@ func (b *Board) NumIssues(ctx context.Context) int { return int(c) } +func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) { + issues := make([]*ProjectIssue, 0, 5) + if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID). + And("project_board_id=?", b.ID). + OrderBy("sorting, id"). + Find(&issues); err != nil { + return nil, err + } + return issues, nil +} + func init() { db.RegisterModel(new(Board)) } @@ -150,12 +163,27 @@ func createBoardsForProjectsType(ctx context.Context, project *Project) error { return db.Insert(ctx, boards) } +// maxProjectColumns max columns allowed in a project, this should not bigger than 127 +// because sorting is int8 in database +const maxProjectColumns = 20 + // NewBoard adds a new project board to a given project func NewBoard(ctx context.Context, board *Board) error { if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) { return fmt.Errorf("bad color code: %s", board.Color) } - + res := struct { + MaxSorting int64 + ColumnCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board"). + Where("project_id=?", board.ProjectID).Get(&res); err != nil { + return err + } + if res.ColumnCount >= maxProjectColumns { + return fmt.Errorf("NewBoard: maximum number of columns reached") + } + board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0)) _, err := db.GetEngine(ctx).Insert(board) return err } @@ -189,7 +217,17 @@ func deleteBoardByID(ctx context.Context, boardID int64) error { return fmt.Errorf("deleteBoardByID: cannot delete default board") } - if err = board.removeIssues(ctx); err != nil { + // move all issues to the default column + project, err := GetProjectByID(ctx, board.ProjectID) + if err != nil { + return err + } + defaultColumn, err := project.GetDefaultBoard(ctx) + if err != nil { + return err + } + + if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { return err } @@ -242,21 +280,15 @@ func UpdateBoard(ctx context.Context, board *Board) error { // GetBoards fetches all boards related to a project func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { boards := make([]*Board, 0, 5) - - if err := db.GetEngine(ctx).Where("project_id=? AND `default`=?", p.ID, false).OrderBy("sorting").Find(&boards); err != nil { + if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil { return nil, err } - defaultB, err := p.getDefaultBoard(ctx) - if err != nil { - return nil, err - } - - return append([]*Board{defaultB}, boards...), nil + return boards, nil } -// getDefaultBoard return default board and ensure only one exists -func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { +// GetDefaultBoard return default board and ensure only one exists +func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) { var board Board has, err := db.GetEngine(ctx). Where("project_id=? AND `default` = ?", p.ID, true). @@ -316,3 +348,42 @@ func UpdateBoardSorting(ctx context.Context, bs BoardList) error { return nil }) } + +func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) { + columns := make([]*Board, 0, 5) + if err := db.GetEngine(ctx). + Where("project_id =?", projectID). + In("id", columnsIDs). + OrderBy("sorting").Find(&columns); err != nil { + return nil, err + } + return columns, nil +} + +// MoveColumnsOnProject sorts columns in a project +func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + sess := db.GetEngine(ctx) + columnIDs := util.ValuesOfMap(sortedColumnIDs) + movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs) + if err != nil { + return err + } + if len(movedColumns) != len(sortedColumnIDs) { + return errors.New("some columns do not exist") + } + + for _, column := range movedColumns { + if column.ProjectID != project.ID { + return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID) + } + } + + for sorting, columnID := range sortedColumnIDs { + if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil { + return err + } + } + return nil + }) +} diff --git a/models/project/board_test.go b/models/project/board_test.go index 71ba29a589..da922ff7ad 100644 --- a/models/project/board_test.go +++ b/models/project/board_test.go @@ -4,6 +4,8 @@ package project import ( + "fmt" + "strings" "testing" "code.gitea.io/gitea/models/db" @@ -19,7 +21,7 @@ func TestGetDefaultBoard(t *testing.T) { assert.NoError(t, err) // check if default board was added - board, err := projectWithoutDefault.getDefaultBoard(db.DefaultContext) + board, err := projectWithoutDefault.GetDefaultBoard(db.DefaultContext) assert.NoError(t, err) assert.Equal(t, int64(5), board.ProjectID) assert.Equal(t, "Uncategorized", board.Title) @@ -28,7 +30,7 @@ func TestGetDefaultBoard(t *testing.T) { assert.NoError(t, err) // check if multiple defaults were removed - board, err = projectWithMultipleDefaults.getDefaultBoard(db.DefaultContext) + board, err = projectWithMultipleDefaults.GetDefaultBoard(db.DefaultContext) assert.NoError(t, err) assert.Equal(t, int64(6), board.ProjectID) assert.Equal(t, int64(9), board.ID) @@ -42,3 +44,84 @@ func TestGetDefaultBoard(t *testing.T) { assert.Equal(t, int64(6), board.ProjectID) assert.False(t, board.Default) } + +func Test_moveIssuesToAnotherColumn(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + column1 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 1, ProjectID: 1}) + + issues, err := column1.GetIssues(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, issues, 1) + assert.EqualValues(t, 1, issues[0].ID) + + column2 := unittest.AssertExistsAndLoadBean(t, &Board{ID: 2, ProjectID: 1}) + issues, err = column2.GetIssues(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, issues, 1) + assert.EqualValues(t, 3, issues[0].ID) + + err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2) + assert.NoError(t, err) + + issues, err = column1.GetIssues(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, issues, 0) + + issues, err = column2.GetIssues(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.EqualValues(t, 3, issues[0].ID) + assert.EqualValues(t, 0, issues[0].Sorting) + assert.EqualValues(t, 1, issues[1].ID) + assert.EqualValues(t, 1, issues[1].Sorting) +} + +func Test_MoveColumnsOnProject(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) + columns, err := project1.GetBoards(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, columns, 3) + assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work + assert.EqualValues(t, 0, columns[1].Sorting) + assert.EqualValues(t, 0, columns[2].Sorting) + + err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{ + 0: columns[1].ID, + 1: columns[2].ID, + 2: columns[0].ID, + }) + assert.NoError(t, err) + + columnsAfter, err := project1.GetBoards(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, columnsAfter, 3) + assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) + assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID) + assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) +} + +func Test_NewBoard(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}) + columns, err := project1.GetBoards(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, columns, 3) + + for i := 0; i < maxProjectColumns-3; i++ { + err := NewBoard(db.DefaultContext, &Board{ + Title: fmt.Sprintf("board-%d", i+4), + ProjectID: project1.ID, + }) + assert.NoError(t, err) + } + err = NewBoard(db.DefaultContext, &Board{ + Title: "board-21", + ProjectID: project1.ID, + }) + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached")) +} diff --git a/models/project/issue.go b/models/project/issue.go index ebc9719de5..32e72e909d 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) // ProjectIssue saves relation from issue to a project @@ -17,7 +18,7 @@ type ProjectIssue struct { //revive:disable-line:exported IssueID int64 `xorm:"INDEX"` ProjectID int64 `xorm:"INDEX"` - // If 0, then it has not been added to a specific board in the project + // ProjectBoardID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors. ProjectBoardID int64 `xorm:"INDEX"` // the sorting order on the board @@ -79,11 +80,8 @@ func (p *Project) NumOpenIssues(ctx context.Context) int { func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error { return db.WithTx(ctx, func(ctx context.Context) error { sess := db.GetEngine(ctx) + issueIDs := util.ValuesOfMap(sortedIssueIDs) - issueIDs := make([]int64, 0, len(sortedIssueIDs)) - for _, issueID := range sortedIssueIDs { - issueIDs = append(issueIDs, issueID) - } count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count() if err != nil { return err @@ -102,7 +100,44 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs }) } -func (b *Board) removeIssues(ctx context.Context) error { - _, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID) - return err +func (b *Board) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Board) error { + if b.ProjectID != newColumn.ProjectID { + return fmt.Errorf("columns have to be in the same project") + } + + if b.ID == newColumn.ID { + return nil + } + + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + Table("project_issue"). + Where("project_id=?", newColumn.ProjectID). + And("project_board_id=?", newColumn.ID). + Get(&res); err != nil { + return err + } + + issues, err := b.GetIssues(ctx) + if err != nil { + return err + } + if len(issues) == 0 { + return nil + } + + nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + return db.WithTx(ctx, func(ctx context.Context) error { + for i, issue := range issues { + issue.ProjectBoardID = newColumn.ID + issue.Sorting = nextSorting + int64(i) + if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { + return err + } + } + return nil + }) } diff --git a/models/project/project.go b/models/project/project.go index 8f9ee2a99e..8be38694c5 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -161,6 +161,13 @@ func (p *Project) IsRepositoryProject() bool { return p.Type == TypeRepository } +func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool { + if p.Type == TypeRepository { + return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored + } + return p.OwnerID == ownerID && p.RepoID == 0 +} + func init() { db.RegisterModel(new(Project)) } diff --git a/models/repo/avatar.go b/models/repo/avatar.go index 72ee938ada..8395b8c2b7 100644 --- a/models/repo/avatar.go +++ b/models/repo/avatar.go @@ -9,10 +9,10 @@ import ( "image/png" "io" "net/url" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/avatar" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -84,13 +84,7 @@ func (repo *Repository) relAvatarLink(ctx context.Context) string { return setting.AppSubURL + "/repo-avatars/" + url.PathEscape(repo.Avatar) } -// AvatarLink returns a link to the repository's avatar. +// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment func (repo *Repository) AvatarLink(ctx context.Context) string { - link := repo.relAvatarLink(ctx) - // we only prepend our AppURL to our known (relative, internal) avatar link to get an absolute URL - if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") { - return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:] - } - // otherwise, return the link as it is - return link + return httplib.MakeAbsoluteURL(ctx, repo.relAvatarLink(ctx)) } diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 987c7df9b0..eacc98e222 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -175,6 +175,8 @@ type SearchRepoOptions struct { // True -> include just forks // False -> include just non-forks Fork optional.Option[bool] + // If Fork option is True, you can use this option to limit the forks of a special repo by repo id. + ForkFrom int64 // None -> include templates AND non-templates // True -> include just templates // False -> include just non-templates @@ -514,6 +516,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond { cond = cond.And(builder.Eq{"is_fork": false}) } else { cond = cond.And(builder.Eq{"is_fork": opts.Fork.Value()}) + + if opts.ForkFrom > 0 && opts.Fork.Value() { + cond = cond.And(builder.Eq{"fork_id": opts.ForkFrom}) + } } } diff --git a/models/repo/search.go b/models/repo/search.go index 4d64acf8cf..54d6dcfb44 100644 --- a/models/repo/search.go +++ b/models/repo/search.go @@ -8,14 +8,14 @@ import "code.gitea.io/gitea/models/db" // SearchOrderByMap represents all possible search order var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{ "asc": { - "alpha": db.SearchOrderByAlphabetically, + "alpha": "owner_name ASC, name ASC", "created": db.SearchOrderByOldest, "updated": db.SearchOrderByLeastUpdated, "size": db.SearchOrderBySize, "id": db.SearchOrderByID, }, "desc": { - "alpha": db.SearchOrderByAlphabeticallyReverse, + "alpha": "owner_name DESC, name DESC", "created": db.SearchOrderByNewest, "updated": db.SearchOrderByRecentUpdated, "size": db.SearchOrderBySizeReverse, diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 1c5412fe7d..c305603e02 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -130,7 +130,10 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us // and just waste 1 unit is cheaper than re-allocate memory once. users := make([]*user_model.User, 0, len(uniqueUserIDs)+1) if len(userIDs) > 0 { - if err = e.In("id", uniqueUserIDs.Values()).OrderBy(user_model.GetOrderByName()).Find(&users); err != nil { + if err = e.In("id", uniqueUserIDs.Values()). + Where(builder.Eq{"`user`.is_active": true}). + OrderBy(user_model.GetOrderByName()). + Find(&users); err != nil { return nil, err } } @@ -152,7 +155,8 @@ func GetReviewers(ctx context.Context, repo *Repository, doerID, posterID int64) return nil, err } - cond := builder.And(builder.Neq{"`user`.id": posterID}) + cond := builder.And(builder.Neq{"`user`.id": posterID}). + And(builder.Eq{"`user`.is_active": true}) if repo.IsPrivate || repo.Owner.Visibility == api.VisibleTypePrivate { // This a private repository: diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index 591dcea5b5..d2bf6dc912 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -25,8 +26,17 @@ func TestRepoAssignees(t *testing.T) { repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21}) users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21) assert.NoError(t, err) - assert.Len(t, users, 4) - assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID}) + if assert.Len(t, users, 4) { + assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID}) + } + + // do not return deactivated users + assert.NoError(t, user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 15, IsActive: false}, "is_active")) + users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21) + assert.NoError(t, err) + if assert.Len(t, users, 3) { + assert.NotContains(t, []int64{users[0].ID, users[1].ID, users[2].ID}, 15) + } } func TestRepoGetReviewers(t *testing.T) { @@ -38,17 +48,19 @@ func TestRepoGetReviewers(t *testing.T) { ctx := db.DefaultContext reviewers, err := repo_model.GetReviewers(ctx, repo1, 2, 2) assert.NoError(t, err) - assert.Len(t, reviewers, 4) + if assert.Len(t, reviewers, 3) { + assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID}) + } // should include doer if doer is not PR poster. reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 2) assert.NoError(t, err) - assert.Len(t, reviewers, 4) + assert.Len(t, reviewers, 3) // should not include PR poster, if PR poster would be otherwise eligible reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 4) assert.NoError(t, err) - assert.Len(t, reviewers, 3) + assert.Len(t, reviewers, 2) // test private user repo repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) diff --git a/models/unit/unit.go b/models/unit/unit.go index a78a2f1e47..74efa4caf0 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strings" + "sync/atomic" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/modules/container" @@ -106,10 +107,23 @@ var ( TypeExternalTracker, } - // DisabledRepoUnits contains the units that have been globally disabled - DisabledRepoUnits = []Type{} + disabledRepoUnitsAtomic atomic.Pointer[[]Type] // the units that have been globally disabled ) +// DisabledRepoUnitsGet returns the globally disabled units, it is a quick patch to fix data-race during testing. +// Because the queue worker might read when a test is mocking the value. FIXME: refactor to a clear solution later. +func DisabledRepoUnitsGet() []Type { + v := disabledRepoUnitsAtomic.Load() + if v == nil { + return nil + } + return *v +} + +func DisabledRepoUnitsSet(v []Type) { + disabledRepoUnitsAtomic.Store(&v) +} + // Get valid set of default repository units from settings func validateDefaultRepoUnits(defaultUnits, settingDefaultUnits []Type) []Type { units := defaultUnits @@ -127,7 +141,7 @@ func validateDefaultRepoUnits(defaultUnits, settingDefaultUnits []Type) []Type { } // Remove disabled units - for _, disabledUnit := range DisabledRepoUnits { + for _, disabledUnit := range DisabledRepoUnitsGet() { for i, unit := range units { if unit == disabledUnit { units = append(units[:i], units[i+1:]...) @@ -140,11 +154,11 @@ func validateDefaultRepoUnits(defaultUnits, settingDefaultUnits []Type) []Type { // LoadUnitConfig load units from settings func LoadUnitConfig() error { - var invalidKeys []string - DisabledRepoUnits, invalidKeys = FindUnitTypes(setting.Repository.DisabledRepoUnits...) + disabledRepoUnits, invalidKeys := FindUnitTypes(setting.Repository.DisabledRepoUnits...) if len(invalidKeys) > 0 { log.Warn("Invalid keys in disabled repo units: %s", strings.Join(invalidKeys, ", ")) } + DisabledRepoUnitsSet(disabledRepoUnits) setDefaultRepoUnits, invalidKeys := FindUnitTypes(setting.Repository.DefaultRepoUnits...) if len(invalidKeys) > 0 { @@ -167,7 +181,7 @@ func LoadUnitConfig() error { // UnitGlobalDisabled checks if unit type is global disabled func (u Type) UnitGlobalDisabled() bool { - for _, ud := range DisabledRepoUnits { + for _, ud := range DisabledRepoUnitsGet() { if u == ud { return true } diff --git a/models/unit/unit_test.go b/models/unit/unit_test.go index d80d8b118d..7bf6326145 100644 --- a/models/unit/unit_test.go +++ b/models/unit/unit_test.go @@ -14,10 +14,10 @@ import ( func TestLoadUnitConfig(t *testing.T) { t.Run("regular", func(t *testing.T) { defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []Type) { - DisabledRepoUnits = disabledRepoUnits + DisabledRepoUnitsSet(disabledRepoUnits) DefaultRepoUnits = defaultRepoUnits DefaultForkRepoUnits = defaultForkRepoUnits - }(DisabledRepoUnits, DefaultRepoUnits, DefaultForkRepoUnits) + }(DisabledRepoUnitsGet(), DefaultRepoUnits, DefaultForkRepoUnits) defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []string) { setting.Repository.DisabledRepoUnits = disabledRepoUnits setting.Repository.DefaultRepoUnits = defaultRepoUnits @@ -28,16 +28,16 @@ func TestLoadUnitConfig(t *testing.T) { setting.Repository.DefaultRepoUnits = []string{"repo.code", "repo.releases", "repo.issues", "repo.pulls"} setting.Repository.DefaultForkRepoUnits = []string{"repo.releases"} assert.NoError(t, LoadUnitConfig()) - assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnits) + assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnitsGet()) assert.Equal(t, []Type{TypeCode, TypeReleases, TypePullRequests}, DefaultRepoUnits) assert.Equal(t, []Type{TypeReleases}, DefaultForkRepoUnits) }) t.Run("invalid", func(t *testing.T) { defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []Type) { - DisabledRepoUnits = disabledRepoUnits + DisabledRepoUnitsSet(disabledRepoUnits) DefaultRepoUnits = defaultRepoUnits DefaultForkRepoUnits = defaultForkRepoUnits - }(DisabledRepoUnits, DefaultRepoUnits, DefaultForkRepoUnits) + }(DisabledRepoUnitsGet(), DefaultRepoUnits, DefaultForkRepoUnits) defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []string) { setting.Repository.DisabledRepoUnits = disabledRepoUnits setting.Repository.DefaultRepoUnits = defaultRepoUnits @@ -48,16 +48,16 @@ func TestLoadUnitConfig(t *testing.T) { setting.Repository.DefaultRepoUnits = []string{"repo.code", "invalid.2", "repo.releases", "repo.issues", "repo.pulls"} setting.Repository.DefaultForkRepoUnits = []string{"invalid.3", "repo.releases"} assert.NoError(t, LoadUnitConfig()) - assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnits) + assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnitsGet()) assert.Equal(t, []Type{TypeCode, TypeReleases, TypePullRequests}, DefaultRepoUnits) assert.Equal(t, []Type{TypeReleases}, DefaultForkRepoUnits) }) t.Run("duplicate", func(t *testing.T) { defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []Type) { - DisabledRepoUnits = disabledRepoUnits + DisabledRepoUnitsSet(disabledRepoUnits) DefaultRepoUnits = defaultRepoUnits DefaultForkRepoUnits = defaultForkRepoUnits - }(DisabledRepoUnits, DefaultRepoUnits, DefaultForkRepoUnits) + }(DisabledRepoUnitsGet(), DefaultRepoUnits, DefaultForkRepoUnits) defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []string) { setting.Repository.DisabledRepoUnits = disabledRepoUnits setting.Repository.DefaultRepoUnits = defaultRepoUnits @@ -68,16 +68,16 @@ func TestLoadUnitConfig(t *testing.T) { setting.Repository.DefaultRepoUnits = []string{"repo.code", "repo.releases", "repo.issues", "repo.pulls", "repo.code"} setting.Repository.DefaultForkRepoUnits = []string{"repo.releases", "repo.releases"} assert.NoError(t, LoadUnitConfig()) - assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnits) + assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnitsGet()) assert.Equal(t, []Type{TypeCode, TypeReleases, TypePullRequests}, DefaultRepoUnits) assert.Equal(t, []Type{TypeReleases}, DefaultForkRepoUnits) }) t.Run("empty_default", func(t *testing.T) { defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []Type) { - DisabledRepoUnits = disabledRepoUnits + DisabledRepoUnitsSet(disabledRepoUnits) DefaultRepoUnits = defaultRepoUnits DefaultForkRepoUnits = defaultForkRepoUnits - }(DisabledRepoUnits, DefaultRepoUnits, DefaultForkRepoUnits) + }(DisabledRepoUnitsGet(), DefaultRepoUnits, DefaultForkRepoUnits) defer func(disabledRepoUnits, defaultRepoUnits, defaultForkRepoUnits []string) { setting.Repository.DisabledRepoUnits = disabledRepoUnits setting.Repository.DefaultRepoUnits = defaultRepoUnits @@ -88,7 +88,7 @@ func TestLoadUnitConfig(t *testing.T) { setting.Repository.DefaultRepoUnits = []string{} setting.Repository.DefaultForkRepoUnits = []string{"repo.releases", "repo.releases"} assert.NoError(t, LoadUnitConfig()) - assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnits) + assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnitsGet()) assert.ElementsMatch(t, []Type{TypeCode, TypePullRequests, TypeReleases, TypeWiki, TypePackages, TypeProjects, TypeActions}, DefaultRepoUnits) assert.Equal(t, []Type{TypeReleases}, DefaultForkRepoUnits) }) diff --git a/models/user/avatar.go b/models/user/avatar.go index c6937d7b51..921bc1b1a1 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -9,11 +9,11 @@ import ( "fmt" "image/png" "io" - "strings" "code.gitea.io/gitea/models/avatars" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/avatar" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -89,13 +89,9 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string { return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size) } -// AvatarLink returns the full avatar link with http host +// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment func (u *User) AvatarLink(ctx context.Context) string { - link := u.AvatarLinkWithSize(ctx, 0) - if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") { - return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/") - } - return link + return httplib.MakeAbsoluteURL(ctx, u.AvatarLinkWithSize(ctx, 0)) } // IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data diff --git a/models/user/email_address.go b/models/user/email_address.go index 08771efe99..71b96c00be 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -10,6 +10,7 @@ import ( "net/mail" "regexp" "strings" + "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" @@ -353,14 +354,12 @@ func ChangeInactivePrimaryEmail(ctx context.Context, uid int64, oldEmailAddr, ne // VerifyActiveEmailCode verifies active email code when active account func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress { - minutes := setting.Service.ActiveCodeLives - if user := GetVerifyUser(ctx, code); user != nil { // time limit code prefix := code[:base.TimeLimitCodeLength] data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands) - if base.VerifyTimeLimitCode(data, minutes, prefix) { + if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { emailAddress := &EmailAddress{UID: user.ID, Email: email} if has, _ := db.GetEngine(ctx).Get(emailAddress); has { return emailAddress diff --git a/models/user/user.go b/models/user/user.go index a5a5b5bdf6..6848d1be95 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -304,7 +304,7 @@ func (u *User) OrganisationLink() string { func (u *User) GenerateEmailActivateCode(email string) string { code := base.CreateTimeLimitCode( fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands), - setting.Service.ActiveCodeLives, nil) + setting.Service.ActiveCodeLives, time.Now(), nil) // Add tail hex username code += hex.EncodeToString([]byte(u.LowerName)) @@ -791,14 +791,11 @@ func GetVerifyUser(ctx context.Context, code string) (user *User) { // VerifyUserActiveCode verifies active code when active account func VerifyUserActiveCode(ctx context.Context, code string) (user *User) { - minutes := setting.Service.ActiveCodeLives - if user = GetVerifyUser(ctx, code); user != nil { // time limit code prefix := code[:base.TimeLimitCodeLength] data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands) - - if base.VerifyTimeLimitCode(data, minutes, prefix) { + if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { return user } } diff --git a/modules/base/tool.go b/modules/base/tool.go index 40785e74e8..378eb7e3dd 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -4,12 +4,15 @@ package base import ( + "crypto/hmac" "crypto/sha1" "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/hex" "errors" "fmt" + "hash" "os" "path/filepath" "runtime" @@ -25,13 +28,6 @@ import ( "github.com/dustin/go-humanize" ) -// EncodeSha1 string to sha1 hex value. -func EncodeSha1(str string) string { - h := sha1.New() - _, _ = h.Write([]byte(str)) - return hex.EncodeToString(h.Sum(nil)) -} - // EncodeSha256 string to sha256 hex value. func EncodeSha256(str string) string { h := sha256.New() @@ -62,63 +58,62 @@ func BasicAuthDecode(encoded string) (string, string, error) { } // VerifyTimeLimitCode verify time limit code -func VerifyTimeLimitCode(data string, minutes int, code string) bool { +func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool { if len(code) <= 18 { return false } - // split code - start := code[:12] - lives := code[12:18] - if d, err := strconv.ParseInt(lives, 10, 0); err == nil { - minutes = int(d) - } + startTimeStr := code[:12] + aliveTimeStr := code[12:18] + aliveTime, _ := strconv.Atoi(aliveTimeStr) // no need to check err, if anything wrong, the following code check will fail soon - // right active code - retCode := CreateTimeLimitCode(data, minutes, start) - if retCode == code && minutes > 0 { - // check time is expired or not - before, _ := time.ParseInLocation("200601021504", start, time.Local) - now := time.Now() - if before.Add(time.Minute*time.Duration(minutes)).Unix() > now.Unix() { - return true + // check code + retCode := CreateTimeLimitCode(data, aliveTime, startTimeStr, nil) + if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 { + retCode = CreateTimeLimitCode(data, aliveTime, startTimeStr, sha1.New()) // TODO: this is only for the support of legacy codes, remove this in/after 1.23 + if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 { + return false } } - return false + // check time is expired or not: startTime <= now && now < startTime + minutes + startTime, _ := time.ParseInLocation("200601021504", startTimeStr, time.Local) + return (startTime.Before(now) || startTime.Equal(now)) && now.Before(startTime.Add(time.Minute*time.Duration(minutes))) } // TimeLimitCodeLength default value for time limit code const TimeLimitCodeLength = 12 + 6 + 40 -// CreateTimeLimitCode create a time limit code -// code format: 12 length date time string + 6 minutes string + 40 sha1 encoded string -func CreateTimeLimitCode(data string, minutes int, startInf any) string { - format := "200601021504" +// CreateTimeLimitCode create a time-limited code. +// Format: 12 length date time string + 6 minutes string (not used) + 40 hash string, some other code depends on this fixed length +// If h is nil, then use the default hmac hash. +func CreateTimeLimitCode[T time.Time | string](data string, minutes int, startTimeGeneric T, h hash.Hash) string { + const format = "200601021504" - var start, end time.Time - var startStr, endStr string - - if startInf == nil { - // Use now time create code - start = time.Now() - startStr = start.Format(format) + var start time.Time + var startTimeAny any = startTimeGeneric + if t, ok := startTimeAny.(time.Time); ok { + start = t } else { - // use start string create code - startStr = startInf.(string) - start, _ = time.ParseInLocation(format, startStr, time.Local) - startStr = start.Format(format) + var err error + start, err = time.ParseInLocation(format, startTimeAny.(string), time.Local) + if err != nil { + return "" // return an invalid code because the "parse" failed + } } + startStr := start.Format(format) + end := start.Add(time.Minute * time.Duration(minutes)) - end = start.Add(time.Minute * time.Duration(minutes)) - endStr = end.Format(format) - - // create sha1 encode string - sh := sha1.New() - _, _ = sh.Write([]byte(fmt.Sprintf("%s%s%s%s%d", data, hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), startStr, endStr, minutes))) - encoded := hex.EncodeToString(sh.Sum(nil)) + if h == nil { + h = hmac.New(sha1.New, setting.GetGeneralTokenSigningSecret()) + } + _, _ = fmt.Fprintf(h, "%s%s%s%s%d", data, hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), startStr, end.Format(format), minutes) + encoded := hex.EncodeToString(h.Sum(nil)) code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded) + if len(code) != TimeLimitCodeLength { + panic("there is a hard requirement for the length of time-limited code") // it shouldn't happen + } return code } diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index f21b89c74c..62de7229ac 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -4,20 +4,18 @@ package base import ( + "crypto/sha1" + "fmt" "os" "testing" "time" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" ) -func TestEncodeSha1(t *testing.T) { - assert.Equal(t, - "8843d7f92416211de9ebb963ff4ce28125932878", - EncodeSha1("foobar"), - ) -} - func TestEncodeSha256(t *testing.T) { assert.Equal(t, "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2", @@ -46,43 +44,54 @@ func TestBasicAuthDecode(t *testing.T) { } func TestVerifyTimeLimitCode(t *testing.T) { - tc := []struct { - data string - minutes int - code string - valid bool - }{{ - data: "data", - minutes: 2, - code: testCreateTimeLimitCode(t, "data", 2), - valid: true, - }, { - data: "abc123-ß", - minutes: 1, - code: testCreateTimeLimitCode(t, "abc123-ß", 1), - valid: true, - }, { - data: "data", - minutes: 2, - code: "2021012723240000005928251dac409d2c33a6eb82c63410aaad569bed", - valid: false, - }} - for _, test := range tc { - actualValid := VerifyTimeLimitCode(test.data, test.minutes, test.code) - assert.Equal(t, test.valid, actualValid, "data: '%s' code: '%s' should be valid: %t", test.data, test.code, test.valid) + defer test.MockVariableValue(&setting.InstallLock, true)() + initGeneralSecret := func(secret string) { + setting.InstallLock = true + setting.CfgProvider, _ = setting.NewConfigProviderFromData(fmt.Sprintf(` +[oauth2] +JWT_SECRET = %s +`, secret)) + setting.LoadCommonSettings() } -} -func testCreateTimeLimitCode(t *testing.T, data string, m int) string { - result0 := CreateTimeLimitCode(data, m, nil) - result1 := CreateTimeLimitCode(data, m, time.Now().Format("200601021504")) - result2 := CreateTimeLimitCode(data, m, time.Unix(time.Now().Unix()+int64(time.Minute)*int64(m), 0).Format("200601021504")) + initGeneralSecret("KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko") + now := time.Now() - assert.Equal(t, result0, result1) - assert.NotEqual(t, result0, result2) + t.Run("TestGenericParameter", func(t *testing.T) { + time2000 := time.Date(2000, 1, 2, 3, 4, 5, 0, time.Local) + assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, time2000, sha1.New())) + assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, "200001020304", sha1.New())) + assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, time2000, nil)) + assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, "200001020304", nil)) + }) - assert.True(t, len(result0) != 0) - return result0 + t.Run("TestInvalidCode", func(t *testing.T) { + assert.False(t, VerifyTimeLimitCode(now, "data", 2, "")) + assert.False(t, VerifyTimeLimitCode(now, "data", 2, "invalid code")) + }) + + t.Run("TestCreateAndVerify", func(t *testing.T) { + code := CreateTimeLimitCode("data", 2, now, nil) + assert.False(t, VerifyTimeLimitCode(now.Add(-time.Minute), "data", 2, code)) // not started yet + assert.True(t, VerifyTimeLimitCode(now, "data", 2, code)) + assert.True(t, VerifyTimeLimitCode(now.Add(time.Minute), "data", 2, code)) + assert.False(t, VerifyTimeLimitCode(now.Add(time.Minute), "DATA", 2, code)) // invalid data + assert.False(t, VerifyTimeLimitCode(now.Add(2*time.Minute), "data", 2, code)) // expired + }) + + t.Run("TestDifferentSecret", func(t *testing.T) { + // use another secret to ensure the code is invalid for different secret + verifyDataCode := func(c string) bool { + return VerifyTimeLimitCode(now, "data", 2, c) + } + code1 := CreateTimeLimitCode("data", 2, now, sha1.New()) + code2 := CreateTimeLimitCode("data", 2, now, nil) + assert.True(t, verifyDataCode(code1)) + assert.True(t, verifyDataCode(code2)) + initGeneralSecret("000_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko") + assert.False(t, verifyDataCode(code1)) + assert.False(t, verifyDataCode(code2)) + }) } func TestFileSize(t *testing.T) { diff --git a/modules/git/blame.go b/modules/git/blame.go index 69e1b08f93..a9b2706f21 100644 --- a/modules/git/blame.go +++ b/modules/git/blame.go @@ -132,7 +132,7 @@ func (r *BlameReader) Close() error { // CreateBlameReader creates reader for given repository, commit and file func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (*BlameReader, error) { var ignoreRevsFile *string - if CheckGitVersionAtLeast("2.23") == nil && !bypassBlameIgnore { + if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { ignoreRevsFile = tryCreateBlameIgnoreRevsFile(commit) } diff --git a/modules/git/commit.go b/modules/git/commit.go index d96cef37c8..86adaa79a6 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -423,7 +423,7 @@ func (c *Commit) GetSubModule(entryname string) (*SubModule, error) { // GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only') func (c *Commit) GetBranchName() (string, error) { cmd := NewCommand(c.repo.Ctx, "name-rev") - if CheckGitVersionAtLeast("2.13.0") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.13.0") { cmd.AddArguments("--exclude", "refs/tags/*") } cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String()) diff --git a/modules/git/git.go b/modules/git/git.go index e411269f7c..05ca260855 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -22,42 +22,63 @@ import ( "github.com/hashicorp/go-version" ) -// RequiredVersion is the minimum Git version required -const RequiredVersion = "2.0.0" +const RequiredVersion = "2.0.0" // the minimum Git version required + +type Features struct { + gitVersion *version.Version + + UsingGogit bool + SupportProcReceive bool // >= 2.29 + SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ + SupportedObjectFormats []ObjectFormat // sha1, sha256 +} var ( - // GitExecutable is the command name of git - // Could be updated to an absolute path while initialization - GitExecutable = "git" - - // DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx - DefaultContext context.Context - - DefaultFeatures struct { - GitVersion *version.Version - - SupportProcReceive bool // >= 2.29 - SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ - } + GitExecutable = "git" // the command name of git, will be updated to an absolute path during initialization + DefaultContext context.Context // the default context to run git commands in, must be initialized by git.InitXxx + defaultFeatures *Features ) -// 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 DefaultFeatures.GitVersion != nil { - return nil - } +func (f *Features) CheckVersionAtLeast(atLeast string) bool { + return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0 +} +// VersionInfo returns git version information +func (f *Features) VersionInfo() string { + return f.gitVersion.Original() +} + +func DefaultFeatures() *Features { + if defaultFeatures == nil { + if !setting.IsProd || setting.IsInTesting { + log.Warn("git.DefaultFeatures is called before git.InitXxx, initializing with default values") + } + if err := InitSimple(context.Background()); err != nil { + log.Fatal("git.InitSimple failed: %v", err) + } + } + return defaultFeatures +} + +func loadGitVersionFeatures() (*Features, error) { stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil) if runErr != nil { - return runErr + return nil, runErr } ver, err := parseGitVersionLine(strings.TrimSpace(stdout)) - if err == nil { - DefaultFeatures.GitVersion = ver + if err != nil { + return nil, err } - return err + + features := &Features{gitVersion: ver, UsingGogit: isGogit} + features.SupportProcReceive = features.CheckVersionAtLeast("2.29") + features.SupportHashSha256 = features.CheckVersionAtLeast("2.42") && !isGogit + features.SupportedObjectFormats = []ObjectFormat{Sha1ObjectFormat} + if features.SupportHashSha256 { + features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat) + } + return features, nil } func parseGitVersionLine(s string) (*version.Version, error) { @@ -85,56 +106,24 @@ func SetExecutablePath(path string) error { return fmt.Errorf("git not found: %w", err) } GitExecutable = absPath + return nil +} - if err = loadGitVersion(); err != nil { - return fmt.Errorf("unable to load git version: %w", err) - } - - versionRequired, err := version.NewVersion(RequiredVersion) - if err != nil { - return err - } - - if DefaultFeatures.GitVersion.LessThan(versionRequired) { +func ensureGitVersion() error { + if !DefaultFeatures().CheckVersionAtLeast(RequiredVersion) { moreHint := "get git: https://git-scm.com/download/" if runtime.GOOS == "linux" { // there are a lot of CentOS/RHEL users using old git, so we add a special hint for them - if _, err = os.Stat("/etc/redhat-release"); err == nil { + if _, err := os.Stat("/etc/redhat-release"); err == nil { // ius.io is the recommended official(git-scm.com) method to install git moreHint = "get git: https://git-scm.com/download/linux and https://ius.io" } } - return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", DefaultFeatures.GitVersion.Original(), RequiredVersion, moreHint) + return fmt.Errorf("installed git version %q is not supported, Gitea requires git version >= %q, %s", DefaultFeatures().gitVersion.Original(), RequiredVersion, moreHint) } - if err = checkGitVersionCompatibility(DefaultFeatures.GitVersion); err != nil { - return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", DefaultFeatures.GitVersion.String(), err) - } - return nil -} - -// VersionInfo returns git version information -func VersionInfo() string { - if DefaultFeatures.GitVersion == nil { - return "(git not found)" - } - format := "%s" - args := []any{DefaultFeatures.GitVersion.Original()} - // Since git wire protocol has been released from git v2.18 - if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil { - format += ", Wire Protocol %s Enabled" - args = append(args, "Version 2") // for focus color - } - - return fmt.Sprintf(format, args...) -} - -func checkInit() error { - if setting.Git.HomePath == "" { - return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules") - } - if DefaultContext != nil { - log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it") + if err := checkGitVersionCompatibility(DefaultFeatures().gitVersion); err != nil { + return fmt.Errorf("installed git version %s has a known compatibility issue with Gitea: %w, please upgrade (or downgrade) git", DefaultFeatures().gitVersion.String(), err) } return nil } @@ -154,8 +143,12 @@ func HomeDir() string { // InitSimple initializes git module with a very simple step, no config changes, no global command arguments. // This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands. func InitSimple(ctx context.Context) error { - if err := checkInit(); err != nil { - return err + if setting.Git.HomePath == "" { + return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules") + } + + if DefaultContext != nil && (!setting.IsProd || setting.IsInTesting) { + log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it") } DefaultContext = ctx @@ -165,7 +158,24 @@ func InitSimple(ctx context.Context) error { defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second } - return SetExecutablePath(setting.Git.Path) + if err := SetExecutablePath(setting.Git.Path); err != nil { + return err + } + + var err error + defaultFeatures, err = loadGitVersionFeatures() + if err != nil { + return err + } + if err = ensureGitVersion(); err != nil { + return err + } + + // when git works with gnupg (commit signing), there should be a stable home for gnupg commands + if _, ok := os.LookupEnv("GNUPGHOME"); !ok { + _ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg")) + } + return nil } // InitFull initializes git module with version check and change global variables, sync gitconfig. @@ -175,30 +185,18 @@ func InitFull(ctx context.Context) (err error) { return err } - // when git works with gnupg (commit signing), there should be a stable home for gnupg commands - if _, ok := os.LookupEnv("GNUPGHOME"); !ok { - _ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg")) - } - // Since git wire protocol has been released from git v2.18 - if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil { + if setting.Git.EnableAutoGitWireProtocol && DefaultFeatures().CheckVersionAtLeast("2.18") { globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2") } // Explicitly disable credential helper, otherwise Git credentials might leak - if CheckGitVersionAtLeast("2.9") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.9") { globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=") } - DefaultFeatures.SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil - DefaultFeatures.SupportHashSha256 = CheckGitVersionAtLeast("2.42") == nil && !isGogit - if DefaultFeatures.SupportHashSha256 { - SupportedObjectFormats = append(SupportedObjectFormats, Sha256ObjectFormat) - } else { - log.Warn("sha256 hash support is disabled - requires Git >= 2.42. Gogit is currently unsupported") - } if setting.LFS.StartServer { - if CheckGitVersionAtLeast("2.1.2") != nil { + if !DefaultFeatures().CheckVersionAtLeast("2.1.2") { return errors.New("LFS server support requires Git >= 2.1.2") } globalCommandArgs = append(globalCommandArgs, "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=") @@ -238,13 +236,13 @@ func syncGitConfig() (err error) { return err } - if CheckGitVersionAtLeast("2.10") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.10") { if err := configSet("receive.advertisePushOptions", "true"); err != nil { return err } } - if CheckGitVersionAtLeast("2.18") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.18") { if err := configSet("core.commitGraph", "true"); err != nil { return err } @@ -256,7 +254,7 @@ func syncGitConfig() (err error) { } } - if DefaultFeatures.SupportProcReceive { + if DefaultFeatures().SupportProcReceive { // set support for AGit flow if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil { return err @@ -294,7 +292,7 @@ func syncGitConfig() (err error) { } // By default partial clones are disabled, enable them from git v2.22 - if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil { + if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") { if err = configSet("uploadpack.allowfilter", "true"); err != nil { return err } @@ -309,21 +307,6 @@ func syncGitConfig() (err error) { return err } -// CheckGitVersionAtLeast check git version is at least the constraint version -func CheckGitVersionAtLeast(atLeast string) error { - if DefaultFeatures.GitVersion == nil { - panic("git module is not initialized") // it shouldn't happen - } - atLeastVersion, err := version.NewVersion(atLeast) - if err != nil { - return err - } - if DefaultFeatures.GitVersion.Compare(atLeastVersion) < 0 { - return fmt.Errorf("installed git binary version %s is not at least %s", DefaultFeatures.GitVersion.Original(), atLeast) - } - return nil -} - func checkGitVersionCompatibility(gitVer *version.Version) error { badVersions := []struct { Version *version.Version diff --git a/modules/git/grep.go b/modules/git/grep.go index e7d238e586..bf6b41a886 100644 --- a/modules/git/grep.go +++ b/modules/git/grep.go @@ -29,6 +29,7 @@ type GrepOptions struct { ContextLineNumber int IsFuzzy bool MaxLineLength int // the maximum length of a line to parse, exceeding chars will be truncated + PathspecList []string } func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) { @@ -62,6 +63,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO cmd.AddOptionValues("-e", strings.TrimLeft(search, "-")) } cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD")) + cmd.AddDashesAndList(opts.PathspecList...) opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50) stderr := bytes.Buffer{} err = cmd.Run(&RunOpts{ diff --git a/modules/git/grep_test.go b/modules/git/grep_test.go index 7f4ded478f..6a99f80407 100644 --- a/modules/git/grep_test.go +++ b/modules/git/grep_test.go @@ -31,6 +31,26 @@ func TestGrepSearch(t *testing.T) { }, }, res) + res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{PathspecList: []string{":(glob)java-hello/*"}}) + assert.NoError(t, err) + assert.Equal(t, []*GrepResult{ + { + Filename: "java-hello/main.java", + LineNumbers: []int{3}, + LineCodes: []string{" public static void main(String[] args)"}, + }, + }, res) + + res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{PathspecList: []string{":(glob,exclude)java-hello/*"}}) + assert.NoError(t, err) + assert.Equal(t, []*GrepResult{ + { + Filename: "main.vendor.java", + LineNumbers: []int{3}, + LineCodes: []string{" public static void main(String[] args)"}, + }, + }, res) + res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1}) assert.NoError(t, err) assert.Equal(t, []*GrepResult{ diff --git a/modules/git/object_format.go b/modules/git/object_format.go index 3de9ff8cf4..242d782e17 100644 --- a/modules/git/object_format.go +++ b/modules/git/object_format.go @@ -120,12 +120,8 @@ var ( Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{} ) -var SupportedObjectFormats = []ObjectFormat{ - Sha1ObjectFormat, -} - func ObjectFormatFromName(name string) ObjectFormat { - for _, objectFormat := range SupportedObjectFormats { + for _, objectFormat := range DefaultFeatures().SupportedObjectFormats { if name == objectFormat.Name() { return objectFormat } diff --git a/modules/git/object_id.go b/modules/git/object_id.go index 33e5085005..82d30184df 100644 --- a/modules/git/object_id.go +++ b/modules/git/object_id.go @@ -54,7 +54,7 @@ func (*Sha256Hash) Type() ObjectFormat { return Sha256ObjectFormat } func NewIDFromString(hexHash string) (ObjectID, error) { var theObjectFormat ObjectFormat - for _, objectFormat := range SupportedObjectFormats { + for _, objectFormat := range DefaultFeatures().SupportedObjectFormats { if len(hexHash) == objectFormat.FullLength() { theObjectFormat = objectFormat break diff --git a/modules/git/remote.go b/modules/git/remote.go index 3585313f6a..7b10e6b663 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -12,7 +12,7 @@ import ( // GetRemoteAddress returns remote url of git repository in the repoPath with special remote name func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, error) { var cmd *Command - if CheckGitVersionAtLeast("2.7") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.7") { cmd = NewCommand(ctx, "remote", "get-url").AddDynamicArguments(remoteName) } else { cmd = NewCommand(ctx, "config", "--get").AddDynamicArguments("remote." + remoteName + ".url") diff --git a/modules/git/repo.go b/modules/git/repo.go index 4511e900e0..1c223018ad 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -7,7 +7,6 @@ package git import ( "bytes" "context" - "errors" "fmt" "io" "net/url" @@ -63,32 +62,6 @@ func IsRepoURLAccessible(ctx context.Context, url string) bool { return err == nil } -// GetObjectFormatOfRepo returns the hash type of repository at a given path -func GetObjectFormatOfRepo(ctx context.Context, repoPath string) (ObjectFormat, error) { - var stdout, stderr strings.Builder - - err := NewCommand(ctx, "hash-object", "--stdin").Run(&RunOpts{ - Dir: repoPath, - Stdout: &stdout, - Stderr: &stderr, - Stdin: &strings.Reader{}, - }) - if err != nil { - return nil, err - } - - if stderr.Len() > 0 { - return nil, errors.New(stderr.String()) - } - - h, err := NewIDFromString(strings.TrimRight(stdout.String(), "\n")) - if err != nil { - return nil, err - } - - return h.Type(), nil -} - // InitRepository initializes a new Git repository. func InitRepository(ctx context.Context, repoPath string, bare bool, objectFormatName string) error { err := os.MkdirAll(repoPath, os.ModePerm) @@ -101,7 +74,7 @@ func InitRepository(ctx context.Context, repoPath string, bare bool, objectForma if !IsValidObjectFormat(objectFormatName) { return fmt.Errorf("invalid object format: %s", objectFormatName) } - if DefaultFeatures.SupportHashSha256 { + if DefaultFeatures().SupportHashSha256 { cmd.AddOptionValues("--object-format", objectFormatName) } diff --git a/modules/git/repo_base.go b/modules/git/repo_base.go deleted file mode 100644 index 6c148d9af5..0000000000 --- a/modules/git/repo_base.go +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -var isGogit bool diff --git a/modules/git/repo_base_gogit.go b/modules/git/repo_base_gogit.go index 0cd07dcdc8..a1127f4e6c 100644 --- a/modules/git/repo_base_gogit.go +++ b/modules/git/repo_base_gogit.go @@ -22,9 +22,7 @@ import ( "github.com/go-git/go-git/v5/storage/filesystem" ) -func init() { - isGogit = true -} +const isGogit = true // Repository represents a Git repository. type Repository struct { diff --git a/modules/git/repo_base_nogogit.go b/modules/git/repo_base_nogogit.go index 5511526e78..bc241cdd79 100644 --- a/modules/git/repo_base_nogogit.go +++ b/modules/git/repo_base_nogogit.go @@ -15,9 +15,7 @@ import ( "code.gitea.io/gitea/modules/util" ) -func init() { - isGogit = false -} +const isGogit = false // Repository represents a Git repository. type Repository struct { diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index f9168bef7e..8c3285769e 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -438,7 +438,7 @@ func (repo *Repository) getCommitsBeforeLimit(id ObjectID, num int) ([]*Commit, } func (repo *Repository) getBranches(commit *Commit, limit int) ([]string, error) { - if CheckGitVersionAtLeast("2.7.0") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.7.0") { stdout, _, err := NewCommand(repo.Ctx, "for-each-ref", "--format=%(refname:strip=2)"). AddOptionFormat("--count=%d", limit). AddOptionValues("--contains", commit.ID.String(), BranchPrefix). diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go index 492438be37..087d5bcec4 100644 --- a/modules/git/repo_commitgraph.go +++ b/modules/git/repo_commitgraph.go @@ -11,7 +11,7 @@ import ( // WriteCommitGraph write commit graph to speed up repo access // this requires git v2.18 to be installed func WriteCommitGraph(ctx context.Context, repoPath string) error { - if CheckGitVersionAtLeast("2.18") == nil { + if DefaultFeatures().CheckVersionAtLeast("2.18") { if _, _, err := NewCommand(ctx, "commit-graph", "write").RunStdString(&RunOpts{Dir: repoPath}); err != nil { return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err) } diff --git a/modules/git/utils.go b/modules/git/utils.go index 0d67412707..53211c6451 100644 --- a/modules/git/utils.go +++ b/modules/git/utils.go @@ -4,6 +4,8 @@ package git import ( + "crypto/sha1" + "encoding/hex" "fmt" "io" "os" @@ -128,3 +130,9 @@ func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) { func (l *LimitedReaderCloser) Close() error { return l.C.Close() } + +func HashFilePathForWebUI(s string) string { + h := sha1.New() + _, _ = h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/modules/git/utils_test.go b/modules/git/utils_test.go new file mode 100644 index 0000000000..1291cee637 --- /dev/null +++ b/modules/git/utils_test.go @@ -0,0 +1,17 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHashFilePathForWebUI(t *testing.T) { + assert.Equal(t, + "8843d7f92416211de9ebb963ff4ce28125932878", + HashFilePathForWebUI("foobar"), + ) +} diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index a193ed901c..6e147d76f5 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -17,11 +17,14 @@ import ( "time" charsetModule "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + + "github.com/klauspost/compress/gzhttp" ) type ServeHeaderOptions struct { @@ -38,6 +41,11 @@ type ServeHeaderOptions struct { func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { header := w.Header() + skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp") + if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) { + w.Header().Add(gzhttp.HeaderNoCompression, "1") + } + contentType := typesniffer.ApplicationOctetStream if opts.ContentType != "" { if opts.ContentTypeCharset != "" { diff --git a/modules/httplib/url.go b/modules/httplib/url.go index 903799cb68..8dc5b71181 100644 --- a/modules/httplib/url.go +++ b/modules/httplib/url.go @@ -4,6 +4,8 @@ package httplib import ( + "context" + "net/http" "net/url" "strings" @@ -11,6 +13,10 @@ import ( "code.gitea.io/gitea/modules/util" ) +type RequestContextKeyStruct struct{} + +var RequestContextKey = RequestContextKeyStruct{} + func urlIsRelative(s string, u *url.URL) bool { // Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH" // Therefore we should ignore these redirect locations to prevent open redirects @@ -26,7 +32,65 @@ func IsRelativeURL(s string) bool { return err == nil && urlIsRelative(s, u) } -func IsCurrentGiteaSiteURL(s string) bool { +func getRequestScheme(req *http.Request) string { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + if s := req.Header.Get("X-Forwarded-Proto"); s != "" { + return s + } + if s := req.Header.Get("X-Forwarded-Protocol"); s != "" { + return s + } + if s := req.Header.Get("X-Url-Scheme"); s != "" { + return s + } + if s := req.Header.Get("Front-End-Https"); s != "" { + return util.Iif(s == "on", "https", "http") + } + if s := req.Header.Get("X-Forwarded-Ssl"); s != "" { + return util.Iif(s == "on", "https", "http") + } + return "" +} + +func getForwardedHost(req *http.Request) string { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host + return req.Header.Get("X-Forwarded-Host") +} + +// GuessCurrentAppURL tries to guess the current full URL by http headers. It always has a '/' suffix, exactly the same as setting.AppURL +func GuessCurrentAppURL(ctx context.Context) string { + req, ok := ctx.Value(RequestContextKey).(*http.Request) + if !ok { + return setting.AppURL + } + // If no scheme provided by reverse proxy, then do not guess the AppURL, use the configured one. + // At the moment, if site admin doesn't configure the proxy headers correctly, then Gitea would guess wrong. + // There are some cases: + // 1. The reverse proxy is configured correctly, it passes "X-Forwarded-Proto/Host" headers. Perfect, Gitea can handle it correctly. + // 2. The reverse proxy is not configured correctly, doesn't pass "X-Forwarded-Proto/Host" headers, eg: only one "proxy_pass http://gitea:3000" in Nginx. + // 3. There is no reverse proxy. + // Without an extra config option, Gitea is impossible to distinguish between case 2 and case 3, + // then case 2 would result in wrong guess like guessed AppURL becomes "http://gitea:3000/", which is not accessible by end users. + // So in the future maybe it should introduce a new config option, to let site admin decide how to guess the AppURL. + reqScheme := getRequestScheme(req) + if reqScheme == "" { + return setting.AppURL + } + reqHost := getForwardedHost(req) + if reqHost == "" { + reqHost = req.Host + } + return reqScheme + "://" + reqHost + setting.AppSubURL + "/" +} + +func MakeAbsoluteURL(ctx context.Context, s string) string { + if IsRelativeURL(s) { + return GuessCurrentAppURL(ctx) + strings.TrimPrefix(s, "/") + } + return s +} + +func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool { u, err := url.Parse(s) if err != nil { return false @@ -45,5 +109,6 @@ func IsCurrentGiteaSiteURL(s string) bool { if u.Path == "" { u.Path = "/" } - return strings.HasPrefix(strings.ToLower(u.String()), strings.ToLower(setting.AppURL)) + urlLower := strings.ToLower(u.String()) + return strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) || strings.HasPrefix(urlLower, strings.ToLower(GuessCurrentAppURL(ctx))) } diff --git a/modules/httplib/url_test.go b/modules/httplib/url_test.go index 9bf09bcf2f..9980cb74e8 100644 --- a/modules/httplib/url_test.go +++ b/modules/httplib/url_test.go @@ -4,6 +4,8 @@ package httplib import ( + "context" + "net/http" "testing" "code.gitea.io/gitea/modules/setting" @@ -37,9 +39,44 @@ func TestIsRelativeURL(t *testing.T) { } } +func TestMakeAbsoluteURL(t *testing.T) { + defer test.MockVariableValue(&setting.Protocol, "http")() + defer test.MockVariableValue(&setting.AppURL, "http://cfg-host/sub/")() + defer test.MockVariableValue(&setting.AppSubURL, "/sub")() + + ctx := context.Background() + assert.Equal(t, "http://cfg-host/sub/", MakeAbsoluteURL(ctx, "")) + assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "foo")) + assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo")) + assert.Equal(t, "http://other/foo", MakeAbsoluteURL(ctx, "http://other/foo")) + + ctx = context.WithValue(ctx, RequestContextKey, &http.Request{ + Host: "user-host", + }) + assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo")) + + ctx = context.WithValue(ctx, RequestContextKey, &http.Request{ + Host: "user-host", + Header: map[string][]string{ + "X-Forwarded-Host": {"forwarded-host"}, + }, + }) + assert.Equal(t, "http://cfg-host/sub/foo", MakeAbsoluteURL(ctx, "/foo")) + + ctx = context.WithValue(ctx, RequestContextKey, &http.Request{ + Host: "user-host", + Header: map[string][]string{ + "X-Forwarded-Host": {"forwarded-host"}, + "X-Forwarded-Proto": {"https"}, + }, + }) + assert.Equal(t, "https://forwarded-host/sub/foo", MakeAbsoluteURL(ctx, "/foo")) +} + func TestIsCurrentGiteaSiteURL(t *testing.T) { defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")() defer test.MockVariableValue(&setting.AppSubURL, "/sub")() + ctx := context.Background() good := []string{ "?key=val", "/sub", @@ -50,7 +87,7 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) { "http://localhost:3000/sub/", } for _, s := range good { - assert.True(t, IsCurrentGiteaSiteURL(s), "good = %q", s) + assert.True(t, IsCurrentGiteaSiteURL(ctx, s), "good = %q", s) } bad := []string{ ".", @@ -64,13 +101,23 @@ func TestIsCurrentGiteaSiteURL(t *testing.T) { "http://other/", } for _, s := range bad { - assert.False(t, IsCurrentGiteaSiteURL(s), "bad = %q", s) + assert.False(t, IsCurrentGiteaSiteURL(ctx, s), "bad = %q", s) } setting.AppURL = "http://localhost:3000/" setting.AppSubURL = "" - assert.False(t, IsCurrentGiteaSiteURL("//")) - assert.False(t, IsCurrentGiteaSiteURL("\\\\")) - assert.False(t, IsCurrentGiteaSiteURL("http://localhost")) - assert.True(t, IsCurrentGiteaSiteURL("http://localhost:3000?key=val")) + assert.False(t, IsCurrentGiteaSiteURL(ctx, "//")) + assert.False(t, IsCurrentGiteaSiteURL(ctx, "\\\\")) + assert.False(t, IsCurrentGiteaSiteURL(ctx, "http://localhost")) + assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000?key=val")) + + ctx = context.WithValue(ctx, RequestContextKey, &http.Request{ + Host: "user-host", + Header: map[string][]string{ + "X-Forwarded-Host": {"forwarded-host"}, + "X-Forwarded-Proto": {"https"}, + }, + }) + assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000")) + assert.True(t, IsCurrentGiteaSiteURL(ctx, "https://forwarded-host")) } diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index bd844205a6..8056b58ec2 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -39,8 +39,6 @@ import ( const ( unicodeNormalizeName = "unicodeNormalize" maxBatchSize = 16 - // fuzzyDenominator determines the levenshtein distance per each character of a keyword - fuzzyDenominator = 4 ) func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error { @@ -245,7 +243,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int phraseQuery.Analyzer = repoIndexerAnalyzer keywordQuery = phraseQuery if opts.IsKeywordFuzzy { - phraseQuery.Fuzziness = len(opts.Keyword) / fuzzyDenominator + phraseQuery.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword) } if len(opts.RepoIDs) > 0 { diff --git a/modules/indexer/internal/bleve/util.go b/modules/indexer/internal/bleve/util.go index 43a7c3c5ec..a2265f86e6 100644 --- a/modules/indexer/internal/bleve/util.go +++ b/modules/indexer/internal/bleve/util.go @@ -47,3 +47,15 @@ func openIndexer(path string, latestVersion int) (bleve.Index, int, error) { return index, 0, nil } + +func GuessFuzzinessByKeyword(s string) int { + // according to https://github.com/blevesearch/bleve/issues/1563, the supported max fuzziness is 2 + // magic number 4 was chosen to determine the levenshtein distance per each character of a keyword + // BUT, when using CJK (eg: `갃갃갃` `啊啊啊`), it mismatches a lot. + for _, r := range s { + if r >= 128 { + return 0 + } + } + return min(2, len(s)/4) +} diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index 1f54be721b..d7957b266a 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -35,11 +35,7 @@ func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error { }) } -const ( - maxBatchSize = 16 - // fuzzyDenominator determines the levenshtein distance per each character of a keyword - fuzzyDenominator = 4 -) +const maxBatchSize = 16 // IndexerData an update to the issue indexer type IndexerData internal.IndexerData @@ -162,7 +158,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( if options.Keyword != "" { fuzziness := 0 if options.IsFuzzyKeyword { - fuzziness = len(options.Keyword) / fuzzyDenominator + fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword) } queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{ diff --git a/modules/issue/template/template.go b/modules/issue/template/template.go index 3be48b9edc..cf5fcf28e5 100644 --- a/modules/issue/template/template.go +++ b/modules/issue/template/template.go @@ -91,6 +91,9 @@ func validateYaml(template *api.IssueTemplate) error { if err := validateOptions(field, idx); err != nil { return err } + if err := validateDropdownDefault(position, field.Attributes); err != nil { + return err + } case api.IssueFormFieldTypeCheckboxes: if err := validateStringItem(position, field.Attributes, false, "description"); err != nil { return err @@ -249,6 +252,28 @@ func validateBoolItem(position errorPosition, m map[string]any, names ...string) return nil } +func validateDropdownDefault(position errorPosition, attributes map[string]any) error { + v, ok := attributes["default"] + if !ok { + return nil + } + defaultValue, ok := v.(int) + if !ok { + return position.Errorf("'default' should be an int") + } + + options, ok := attributes["options"].([]any) + if !ok { + // should not happen + return position.Errorf("'options' is required and should be a array") + } + if defaultValue < 0 || defaultValue >= len(options) { + return position.Errorf("the value of 'default' is out of range") + } + + return nil +} + type errorPosition string func (p errorPosition) Errorf(format string, a ...any) error { diff --git a/modules/issue/template/template_test.go b/modules/issue/template/template_test.go index e24b962d61..481058754d 100644 --- a/modules/issue/template/template_test.go +++ b/modules/issue/template/template_test.go @@ -355,6 +355,96 @@ body: `, wantErr: "body[0](checkboxes), option[1]: can not require a hidden checkbox", }, + { + name: "dropdown default is not an integer", + content: ` +name: "test" +about: "this is about" +body: + - type: dropdown + id: "1" + attributes: + label: Label of dropdown + description: Description of dropdown + multiple: true + options: + - Option 1 of dropdown + - Option 2 of dropdown + - Option 3 of dropdown + default: "def" + validations: + required: true +`, + wantErr: "body[0](dropdown): 'default' should be an int", + }, + { + name: "dropdown default is out of range", + content: ` +name: "test" +about: "this is about" +body: + - type: dropdown + id: "1" + attributes: + label: Label of dropdown + description: Description of dropdown + multiple: true + options: + - Option 1 of dropdown + - Option 2 of dropdown + - Option 3 of dropdown + default: 3 + validations: + required: true +`, + wantErr: "body[0](dropdown): the value of 'default' is out of range", + }, + { + name: "dropdown without default is valid", + content: ` +name: "test" +about: "this is about" +body: + - type: dropdown + id: "1" + attributes: + label: Label of dropdown + description: Description of dropdown + multiple: true + options: + - Option 1 of dropdown + - Option 2 of dropdown + - Option 3 of dropdown + validations: + required: true +`, + want: &api.IssueTemplate{ + Name: "test", + About: "this is about", + Fields: []*api.IssueFormField{ + { + Type: "dropdown", + ID: "1", + Attributes: map[string]any{ + "label": "Label of dropdown", + "description": "Description of dropdown", + "multiple": true, + "options": []any{ + "Option 1 of dropdown", + "Option 2 of dropdown", + "Option 3 of dropdown", + }, + }, + Validations: map[string]any{ + "required": true, + }, + Visible: []api.IssueFormFieldVisible{api.IssueFormFieldVisibleForm, api.IssueFormFieldVisibleContent}, + }, + }, + FileName: "test.yaml", + }, + wantErr: "", + }, { name: "valid", content: ` @@ -399,6 +489,7 @@ body: - Option 1 of dropdown - Option 2 of dropdown - Option 3 of dropdown + default: 1 validations: required: true - type: checkboxes @@ -475,6 +566,7 @@ body: "Option 2 of dropdown", "Option 3 of dropdown", }, + "default": 1, }, Validations: map[string]any{ "required": true, diff --git a/modules/lfs/pointer_scanner_nogogit.go b/modules/lfs/pointer_scanner_nogogit.go index 658b98feab..c37a93e73b 100644 --- a/modules/lfs/pointer_scanner_nogogit.go +++ b/modules/lfs/pointer_scanner_nogogit.go @@ -41,7 +41,7 @@ func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan c go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) // 1. Run batch-check on all objects in the repository - if git.CheckGitVersionAtLeast("2.6.0") != nil { + if !git.DefaultFeatures().CheckVersionAtLeast("2.6.0") { revListReader, revListWriter := io.Pipe() shasToCheckReader, shasToCheckWriter := io.Pipe() wg.Add(2) diff --git a/modules/markup/html.go b/modules/markup/html.go index cef643bf18..2958dc9646 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -10,6 +10,7 @@ import ( "path" "path/filepath" "regexp" + "slices" "strings" "sync" @@ -54,7 +55,7 @@ var ( shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) // anyHashPattern splits url containing SHA into parts - anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~_%.a-zA-Z0-9/]+)?(#[-+~_%.a-zA-Z0-9]+)?`) + anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) @@ -591,17 +592,17 @@ func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) { func mentionProcessor(ctx *RenderContext, node *html.Node) { start := 0 - next := node.NextSibling - for node != nil && node != next && start < len(node.Data) { - // We replace only the first mention; other mentions will be addressed later - found, loc := references.FindFirstMentionBytes([]byte(node.Data[start:])) + nodeStop := node.NextSibling + for node != nodeStop { + found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:])) if !found { - return + node = node.NextSibling + start = 0 + continue } loc.Start += start loc.End += start mention := node.Data[loc.Start:loc.End] - var teams string teams, ok := ctx.Metas["teams"] // FIXME: util.URLJoin may not be necessary here: // - setting.AppURL is defined to have a terminal '/' so unless mention[1:] @@ -623,10 +624,10 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) { if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention")) node = node.NextSibling.NextSibling + start = 0 } else { - node = node.NextSibling + start = loc.End } - start = 0 } } @@ -963,57 +964,68 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) { } } +type anyHashPatternResult struct { + PosStart int + PosEnd int + FullURL string + CommitID string + SubPath string + QueryHash string +} + +func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { + m := anyHashPattern.FindStringSubmatchIndex(s) + if m == nil { + return ret, false + } + + ret.PosStart, ret.PosEnd = m[0], m[1] + ret.FullURL = s[ret.PosStart:ret.PosEnd] + if strings.HasSuffix(ret.FullURL, ".") { + // if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence. + ret.PosEnd-- + ret.FullURL = ret.FullURL[:len(ret.FullURL)-1] + for i := 0; i < len(m); i++ { + m[i] = min(m[i], ret.PosEnd) + } + } + + ret.CommitID = s[m[2]:m[3]] + if m[5] > 0 { + ret.SubPath = s[m[4]:m[5]] + } + + lastStart, lastEnd := m[len(m)-2], m[len(m)-1] + if lastEnd > 0 { + ret.QueryHash = s[lastStart:lastEnd][1:] + } + return ret, true +} + // fullHashPatternProcessor renders SHA containing URLs func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) { if ctx.Metas == nil { return } - - next := node.NextSibling - for node != nil && node != next { - m := anyHashPattern.FindStringSubmatchIndex(node.Data) - if m == nil { - return + nodeStop := node.NextSibling + for node != nodeStop { + if node.Type != html.TextNode { + node = node.NextSibling + continue } - - urlFull := node.Data[m[0]:m[1]] - text := base.ShortSha(node.Data[m[2]:m[3]]) - - // 3rd capture group matches a optional path - subpath := "" - if m[5] > 0 { - subpath = node.Data[m[4]:m[5]] + ret, ok := anyHashPatternExtract(node.Data) + if !ok { + node = node.NextSibling + continue } - - // 4th capture group matches a optional url hash - hash := "" - if m[7] > 0 { - hash = node.Data[m[6]:m[7]][1:] + text := base.ShortSha(ret.CommitID) + if ret.SubPath != "" { + text += ret.SubPath } - - start := m[0] - end := m[1] - - // If url ends in '.', it's very likely that it is not part of the - // actual url but used to finish a sentence. - if strings.HasSuffix(urlFull, ".") { - end-- - urlFull = urlFull[:len(urlFull)-1] - if hash != "" { - hash = hash[:len(hash)-1] - } else if subpath != "" { - subpath = subpath[:len(subpath)-1] - } + if ret.QueryHash != "" { + text += " (" + ret.QueryHash + ")" } - - if subpath != "" { - text += subpath - } - - if hash != "" { - text += " (" + hash + ")" - } - replaceContent(node, start, end, createCodeLink(urlFull, text, "commit")) + replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit")) node = node.NextSibling.NextSibling } } @@ -1022,19 +1034,16 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) { if ctx.Metas == nil { return } - - next := node.NextSibling - for node != nil && node != next { - m := comparePattern.FindStringSubmatchIndex(node.Data) - if m == nil { - return + nodeStop := node.NextSibling + for node != nodeStop { + if node.Type != html.TextNode { + node = node.NextSibling + continue } - - // Ensure that every group (m[0]...m[7]) has a match - for i := 0; i < 8; i++ { - if m[i] == -1 { - return - } + m := comparePattern.FindStringSubmatchIndex(node.Data) + if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match + node = node.NextSibling + continue } urlFull := node.Data[m[0]:m[1]] diff --git a/modules/markup/html_codepreview.go b/modules/markup/html_codepreview.go index d9da24ea34..5ab9290b3e 100644 --- a/modules/markup/html_codepreview.go +++ b/modules/markup/html_codepreview.go @@ -42,7 +42,7 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt CommitID: node.Data[m[6]:m[7]], FilePath: node.Data[m[8]:m[9]], } - if !httplib.IsCurrentGiteaSiteURL(opts.FullURL) { + if !httplib.IsCurrentGiteaSiteURL(ctx.Ctx, opts.FullURL) { return 0, 0, "", nil } u, err := url.Parse(opts.FilePath) @@ -60,7 +60,8 @@ func renderCodeBlock(ctx *RenderContext, node *html.Node) (urlPosStart, urlPosSt } func codePreviewPatternProcessor(ctx *RenderContext, node *html.Node) { - for node != nil { + nodeStop := node.NextSibling + for node != nodeStop { if node.Type != html.TextNode { node = node.NextSibling continue diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go index e313be7040..3ff0597851 100644 --- a/modules/markup/html_internal_test.go +++ b/modules/markup/html_internal_test.go @@ -399,36 +399,61 @@ func TestRegExp_sha1CurrentPattern(t *testing.T) { } func TestRegExp_anySHA1Pattern(t *testing.T) { - testCases := map[string][]string{ + testCases := map[string]anyHashPatternResult{ "https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js#L2703": { - "a644101ed04d0beacea864ce805e0c4f86ba1cd1", - "/test/unit/event.js", - "#L2703", + CommitID: "a644101ed04d0beacea864ce805e0c4f86ba1cd1", + SubPath: "/test/unit/event.js", + QueryHash: "L2703", }, "https://github.com/jquery/jquery/blob/a644101ed04d0beacea864ce805e0c4f86ba1cd1/test/unit/event.js": { - "a644101ed04d0beacea864ce805e0c4f86ba1cd1", - "/test/unit/event.js", - "", + CommitID: "a644101ed04d0beacea864ce805e0c4f86ba1cd1", + SubPath: "/test/unit/event.js", }, "https://github.com/jquery/jquery/commit/0705be475092aede1eddae01319ec931fb9c65fc": { - "0705be475092aede1eddae01319ec931fb9c65fc", - "", - "", + CommitID: "0705be475092aede1eddae01319ec931fb9c65fc", }, "https://github.com/jquery/jquery/tree/0705be475092aede1eddae01319ec931fb9c65fc/src": { - "0705be475092aede1eddae01319ec931fb9c65fc", - "/src", - "", + CommitID: "0705be475092aede1eddae01319ec931fb9c65fc", + SubPath: "/src", }, "https://try.gogs.io/gogs/gogs/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2": { - "d8a994ef243349f321568f9e36d5c3f444b99cae", - "", - "#diff-2", + CommitID: "d8a994ef243349f321568f9e36d5c3f444b99cae", + QueryHash: "diff-2", + }, + "non-url": {}, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b#L1-L2": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + QueryHash: "L1-L2", + }, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678.": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + }, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678/sub.": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + SubPath: "/sub", + }, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b.": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + }, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678?a=b&c=d": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + }, + "http://a/b/c/d/e/1234567812345678123456781234567812345678123456781234567812345678#hash.": { + CommitID: "1234567812345678123456781234567812345678123456781234567812345678", + QueryHash: "hash", }, } for k, v := range testCases { - assert.Equal(t, anyHashPattern.FindStringSubmatch(k)[1:], v) + ret, ok := anyHashPatternExtract(k) + if v.CommitID == "" { + assert.False(t, ok) + } else { + assert.EqualValues(t, strings.TrimSuffix(k, "."), ret.FullURL) + assert.EqualValues(t, v.CommitID, ret.CommitID) + assert.EqualValues(t, v.SubPath, ret.SubPath) + assert.EqualValues(t, v.QueryHash, ret.QueryHash) + } } } diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 916e74fb62..a2ae18d777 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -124,6 +124,11 @@ func TestRender_CrossReferences(t *testing.T) { test( util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345"), `

gogitea/some-repo-name#12345

`) + + inputURL := "https://host/a/b/commit/0123456789012345678901234567890123456789/foo.txt?a=b#L2-L3" + test( + inputURL, + `

0123456789/foo.txt (L2-L3)

`) } func TestMisc_IsSameDomain(t *testing.T) { @@ -695,7 +700,7 @@ func TestIssue18471(t *testing.T) { }, strings.NewReader(data), &res) assert.NoError(t, err) - assert.Equal(t, "783b039...da951ce", res.String()) + assert.Equal(t, `783b039...da951ce`, res.String()) } func TestIsFullURL(t *testing.T) { diff --git a/modules/private/hook.go b/modules/private/hook.go index 79c3d48229..49d9298744 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" ) @@ -54,6 +55,7 @@ type HookOptions struct { GitQuarantinePath string GitPushOptions GitPushOptions PullRequestID int64 + PushTrigger repository.PushTrigger DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user. IsWiki bool ActionPerm int diff --git a/modules/references/references.go b/modules/references/references.go index 761d6ee3d1..1b656ed4cb 100644 --- a/modules/references/references.go +++ b/modules/references/references.go @@ -29,7 +29,7 @@ var ( // TODO: fix invalid linking issue // mentionPattern matches all mentions in the form of "@user" or "@org/team" - mentionPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_]+\/?[0-9a-zA-Z-_]+|@[0-9a-zA-Z-_][0-9a-zA-Z-_.]+\/?[0-9a-zA-Z-_.]+[0-9a-zA-Z-_])(?:\s|[:,;.?!]\s|[:,;.?!]?$|\)|\])`) + mentionPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@[-\w][-.\w]*?|@[-\w][-.\w]*?/[-\w][-.\w]*?)(?:\s|$|[:,;.?!](\s|$)|'|\)|\])`) // issueNumericPattern matches string that references to a numeric issue, e.g. #1287 issueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[|\'|\")([#!][0-9]+)(?:\s|$|\)|\]|\'|\"|[:;,.?!]\s|[:;,.?!]$)`) // issueAlphanumericPattern matches string that references to an alphanumeric issue, e.g. ABC-1234 diff --git a/modules/references/references_test.go b/modules/references/references_test.go index 0c32933619..e5a0d60fe3 100644 --- a/modules/references/references_test.go +++ b/modules/references/references_test.go @@ -392,6 +392,7 @@ func TestRegExp_mentionPattern(t *testing.T) { {"@gitea,", "@gitea"}, {"@gitea;", "@gitea"}, {"@gitea/team1;", "@gitea/team1"}, + {"@user's idea", "@user"}, } falseTestCases := []string{ "@ 0", @@ -412,7 +413,6 @@ func TestRegExp_mentionPattern(t *testing.T) { for _, testCase := range trueTestCases { found := mentionPattern.FindStringSubmatch(testCase.pat) - assert.Len(t, found, 2) assert.Equal(t, testCase.exp, found[1]) } for _, testCase := range falseTestCases { diff --git a/modules/repository/branch.go b/modules/repository/branch.go index e448490f4a..a3fca7c7ce 100644 --- a/modules/repository/branch.go +++ b/modules/repository/branch.go @@ -5,6 +5,7 @@ package repository import ( "context" + "fmt" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" @@ -36,6 +37,15 @@ func SyncRepoBranches(ctx context.Context, repoID, doerID int64) (int64, error) } func SyncRepoBranchesWithRepo(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doerID int64) (int64, error) { + objFmt, err := gitRepo.GetObjectFormat() + if err != nil { + return 0, fmt.Errorf("GetObjectFormat: %w", err) + } + _, err = db.GetEngine(ctx).ID(repo.ID).Update(&repo_model.Repository{ObjectFormatName: objFmt.Name()}) + if err != nil { + return 0, fmt.Errorf("UpdateRepository: %w", err) + } + allBranches := container.Set[string]{} { branches, _, err := gitRepo.GetBranchNames(0, 0) diff --git a/modules/repository/branch_test.go b/modules/repository/branch_test.go new file mode 100644 index 0000000000..acf75a1ac0 --- /dev/null +++ b/modules/repository/branch_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestSyncRepoBranches(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + _, err := db.GetEngine(db.DefaultContext).ID(1).Update(&repo_model.Repository{ObjectFormatName: "bad-fmt"}) + assert.NoError(t, db.TruncateBeans(db.DefaultContext, &git_model.Branch{})) + assert.NoError(t, err) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "bad-fmt", repo.ObjectFormatName) + _, err = SyncRepoBranches(db.DefaultContext, 1, 0) + assert.NoError(t, err) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "sha1", repo.ObjectFormatName) + branch, err := git_model.GetBranch(db.DefaultContext, 1, "master") + assert.NoError(t, err) + assert.EqualValues(t, "master", branch.Name) +} diff --git a/modules/repository/env.go b/modules/repository/env.go index 30edd1c9e3..e4f32092fc 100644 --- a/modules/repository/env.go +++ b/modules/repository/env.go @@ -25,11 +25,19 @@ const ( EnvKeyID = "GITEA_KEY_ID" // public key ID EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" EnvPRID = "GITEA_PR_ID" + EnvPushTrigger = "GITEA_PUSH_TRIGGER" EnvIsInternal = "GITEA_INTERNAL_PUSH" EnvAppURL = "GITEA_ROOT_URL" EnvActionPerm = "GITEA_ACTION_PERM" ) +type PushTrigger string + +const ( + PushTriggerPRMergeToBase PushTrigger = "pr-merge-to-base" + PushTriggerPRUpdateWithBase PushTrigger = "pr-update-with-base" +) + // InternalPushingEnvironment returns an os environment to switch off hooks on push // It is recommended to avoid using this unless you are pushing within a transaction // or if you absolutely are sure that post-receive and pre-receive will do nothing diff --git a/modules/setting/glob.go b/modules/setting/glob.go new file mode 100644 index 0000000000..8f1d24dea4 --- /dev/null +++ b/modules/setting/glob.go @@ -0,0 +1,32 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import "github.com/gobwas/glob" + +type GlobMatcher struct { + compiledGlob glob.Glob + patternString string +} + +var _ glob.Glob = (*GlobMatcher)(nil) + +func (g *GlobMatcher) Match(s string) bool { + return g.compiledGlob.Match(s) +} + +func (g *GlobMatcher) PatternString() string { + return g.patternString +} + +func GlobMatcherCompile(pattern string, separators ...rune) (*GlobMatcher, error) { + g, err := glob.Compile(pattern, separators...) + if err != nil { + return nil, err + } + return &GlobMatcher{ + compiledGlob: g, + patternString: pattern, + }, nil +} diff --git a/modules/setting/indexer.go b/modules/setting/indexer.go index 6877d70e3c..18585602c3 100644 --- a/modules/setting/indexer.go +++ b/modules/setting/indexer.go @@ -10,8 +10,6 @@ import ( "time" "code.gitea.io/gitea/modules/log" - - "github.com/gobwas/glob" ) // Indexer settings @@ -30,8 +28,8 @@ var Indexer = struct { RepoConnStr string RepoIndexerName string MaxIndexerFileSize int64 - IncludePatterns []glob.Glob - ExcludePatterns []glob.Glob + IncludePatterns []*GlobMatcher + ExcludePatterns []*GlobMatcher ExcludeVendored bool }{ IssueType: "bleve", @@ -93,12 +91,12 @@ func loadIndexerFrom(rootCfg ConfigProvider) { } // IndexerGlobFromString parses a comma separated list of patterns and returns a glob.Glob slice suited for repo indexing -func IndexerGlobFromString(globstr string) []glob.Glob { - extarr := make([]glob.Glob, 0, 10) +func IndexerGlobFromString(globstr string) []*GlobMatcher { + extarr := make([]*GlobMatcher, 0, 10) for _, expr := range strings.Split(strings.ToLower(globstr), ",") { expr = strings.TrimSpace(expr) if expr != "" { - if g, err := glob.Compile(expr, '.', '/'); err != nil { + if g, err := GlobMatcherCompile(expr, '.', '/'); err != nil { log.Info("Invalid glob expression '%s' (skipped): %v", expr, err) } else { extarr = append(extarr, g) diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index e59f54420b..0d3e63e0b4 100644 --- a/modules/setting/oauth2.go +++ b/modules/setting/oauth2.go @@ -126,16 +126,15 @@ func loadOAuth2From(rootCfg ConfigProvider) { OAuth2.Enabled = sec.Key("ENABLE").MustBool(OAuth2.Enabled) } - if !OAuth2.Enabled { - return - } - - jwtSecretBase64 := loadSecret(sec, "JWT_SECRET_URI", "JWT_SECRET") - if !filepath.IsAbs(OAuth2.JWTSigningPrivateKeyFile) { OAuth2.JWTSigningPrivateKeyFile = filepath.Join(AppDataPath, OAuth2.JWTSigningPrivateKeyFile) } + // FIXME: at the moment, no matter oauth2 is enabled or not, it must generate a "oauth2 JWT_SECRET" + // Because this secret is also used as GeneralTokenSigningSecret (as a quick not-that-breaking fix for some legacy problems). + // Including: CSRF token, account validation token, etc ... + // In main branch, the signing token should be refactored (eg: one unique for LFS/OAuth2/etc ...) + jwtSecretBase64 := loadSecret(sec, "JWT_SECRET_URI", "JWT_SECRET") if InstallLock { jwtSecretBytes, err := generate.DecodeJwtSecretBase64(jwtSecretBase64) if err != nil { @@ -157,8 +156,6 @@ func loadOAuth2From(rootCfg ConfigProvider) { } } -// generalSigningSecret is used as container for a []byte value -// instead of an additional mutex, we use CompareAndSwap func to change the value thread save var generalSigningSecret atomic.Pointer[[]byte] func GetGeneralTokenSigningSecret() []byte { @@ -166,11 +163,9 @@ func GetGeneralTokenSigningSecret() []byte { if old == nil || len(*old) == 0 { jwtSecret, _, err := generate.NewJwtSecretWithBase64() if err != nil { - log.Fatal("Unable to generate general JWT secret: %s", err.Error()) + log.Fatal("Unable to generate general JWT secret: %v", err) } if generalSigningSecret.CompareAndSwap(old, &jwtSecret) { - // FIXME: in main branch, the signing token should be refactored (eg: one unique for LFS/OAuth2/etc ...) - LogStartupProblem(1, log.WARN, "OAuth2 is not enabled, unable to use a persistent signing secret, a new one is generated, which is not persistent between restarts and cluster nodes") return jwtSecret } return *generalSigningSecret.Load() diff --git a/modules/setting/oauth2_test.go b/modules/setting/oauth2_test.go index 4403f35892..38ee4d248d 100644 --- a/modules/setting/oauth2_test.go +++ b/modules/setting/oauth2_test.go @@ -4,6 +4,7 @@ package setting import ( + "os" "testing" "code.gitea.io/gitea/modules/generate" @@ -14,7 +15,7 @@ import ( func TestGetGeneralSigningSecret(t *testing.T) { // when there is no general signing secret, it should be generated, and keep the same value - assert.Nil(t, generalSigningSecret.Load()) + generalSigningSecret.Store(nil) s1 := GetGeneralTokenSigningSecret() assert.NotNil(t, s1) s2 := GetGeneralTokenSigningSecret() @@ -33,6 +34,31 @@ JWT_SECRET = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB assert.EqualValues(t, expected, actual) } +func TestGetGeneralSigningSecretSave(t *testing.T) { + defer test.MockVariableValue(&InstallLock, true)() + + old := GetGeneralTokenSigningSecret() + assert.Len(t, old, 32) + + tmpFile := t.TempDir() + "/app.ini" + _ = os.WriteFile(tmpFile, nil, 0o644) + cfg, _ := NewConfigProviderFromFile(tmpFile) + loadOAuth2From(cfg) + generated := GetGeneralTokenSigningSecret() + assert.Len(t, generated, 32) + assert.NotEqual(t, old, generated) + + generalSigningSecret.Store(nil) + cfg, _ = NewConfigProviderFromFile(tmpFile) + loadOAuth2From(cfg) + again := GetGeneralTokenSigningSecret() + assert.Equal(t, generated, again) + + iniContent, err := os.ReadFile(tmpFile) + assert.NoError(t, err) + assert.Contains(t, string(iniContent), "JWT_SECRET = ") +} + func TestOauth2DefaultApplications(t *testing.T) { cfg, _ := NewConfigProviderFromData(``) loadOAuth2From(cfg) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 16242d18ad..3c06e38356 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -30,6 +30,7 @@ type PullRequestMeta struct { HasMerged bool `json:"merged"` Merged *time.Time `json:"merged_at"` IsWorkInProgress bool `json:"draft"` + HTMLURL string `json:"html_url"` } // RepositoryMeta basic repository information diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go index bf68726d79..942cc0b3a1 100644 --- a/modules/structs/issue_label.go +++ b/modules/structs/issue_label.go @@ -47,8 +47,9 @@ type EditLabelOption struct { // IssueLabelsOption a collection of labels type IssueLabelsOption struct { - // list of label IDs - Labels []int64 `json:"labels"` + // Labels can be a list of integers representing label IDs + // or a list of strings representing label names + Labels []any `json:"labels"` } // LabelTemplate info of a Label template diff --git a/modules/structs/pull.go b/modules/structs/pull.go index 05a8d59633..525d90c28e 100644 --- a/modules/structs/pull.go +++ b/modules/structs/pull.go @@ -21,8 +21,14 @@ type PullRequest struct { Assignees []*User `json:"assignees"` RequestedReviewers []*User `json:"requested_reviewers"` State StateType `json:"state"` + Draft bool `json:"draft"` IsLocked bool `json:"is_locked"` Comments int `json:"comments"` + // number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) + ReviewComments int `json:"review_comments"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` HTMLURL string `json:"html_url"` DiffURL string `json:"diff_url"` @@ -85,7 +91,7 @@ type CreatePullRequestOption struct { // EditPullRequestOption options when modify pull request type EditPullRequestOption struct { Title string `json:"title"` - Body string `json:"body"` + Body *string `json:"body"` Base string `json:"base"` Assignee string `json:"assignee"` Assignees []string `json:"assignees"` diff --git a/modules/structs/repo.go b/modules/structs/repo.go index bc8eb0b756..1fe826cf89 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -217,7 +217,7 @@ type EditRepoOption struct { Archived *bool `json:"archived,omitempty"` // set to a string like `8h30m0s` to set the mirror interval time MirrorInterval *string `json:"mirror_interval,omitempty"` - // enable prune - remove obsolete remote-tracking references + // enable prune - remove obsolete remote-tracking references when mirroring EnablePrune *bool `json:"enable_prune,omitempty"` } diff --git a/modules/structs/user.go b/modules/structs/user.go index ca6ab79944..5ed677f239 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -28,6 +28,8 @@ type User struct { Email string `json:"email"` // URL to the user's avatar AvatarURL string `json:"avatar_url"` + // URL to the user's gitea page + HTMLURL string `json:"html_url"` // User locale Language string `json:"language"` // Is the user an administrator diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 659422aee7..b15de6521d 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -121,29 +121,25 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) // RenderLabel renders a label // locale is needed due to an import cycle with our context providing the `Tr` function func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { - var ( - archivedCSSClass string - textColor = util.ContrastColor(label.Color) - labelScope = label.ExclusiveScope() - ) - - description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) + var extraCSSClasses string + textColor := util.ContrastColor(label.Color) + labelScope := label.ExclusiveScope() + descriptionText := emoji.ReplaceAliases(label.Description) if label.IsArchived() { - archivedCSSClass = "archived-label" - description = fmt.Sprintf("(%s) %s", locale.TrString("archived"), description) + extraCSSClasses = "archived-label" + descriptionText = fmt.Sprintf("(%s) %s", locale.TrString("archived"), descriptionText) } if labelScope == "" { // Regular label - s := fmt.Sprintf("
%s
", - archivedCSSClass, textColor, label.Color, description, RenderEmoji(ctx, label.Name)) - return template.HTML(s) + return HTMLFormat(`
%s
`, + extraCSSClasses, textColor, label.Color, descriptionText, RenderEmoji(ctx, label.Name)) } // Scoped label - scopeText := RenderEmoji(ctx, labelScope) - itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) + scopeHTML := RenderEmoji(ctx, labelScope) + itemHTML := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) // Make scope and item background colors slightly darker and lighter respectively. // More contrast needed with higher luminance, empirically tweaked. @@ -171,14 +167,13 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m itemColor := "#" + hex.EncodeToString(itemBytes) scopeColor := "#" + hex.EncodeToString(scopeBytes) - s := fmt.Sprintf(""+ - "
%s
"+ - "
%s
"+ - "
", - archivedCSSClass, description, - textColor, scopeColor, scopeText, - textColor, itemColor, itemText) - return template.HTML(s) + return HTMLFormat(``+ + `
%s
`+ + `
%s
`+ + `
`, + extraCSSClasses, descriptionText, + textColor, scopeColor, scopeHTML, + textColor, itemColor, itemHTML) } // RenderEmoji renders html text with emoji post processors diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 47c5da6485..f493b899e3 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -207,3 +207,8 @@ func TestRenderLabels(t *testing.T) { expected = `/owner/repo/pulls?labels=123` assert.Contains(t, RenderLabels(ctx, locale, []*issues.Label{label}, "/owner/repo", issue), expected) } + +func TestUserMention(t *testing.T) { + rendered := RenderMarkdownToHtml(context.Background(), "@no-such-user @mention-user @mention-user") + assert.EqualValues(t, `

@no-such-user @mention-user @mention-user

`, strings.TrimSpace(string(rendered))) +} diff --git a/modules/util/color.go b/modules/util/color.go index 9c520dce78..8fffc91ac4 100644 --- a/modules/util/color.go +++ b/modules/util/color.go @@ -1,5 +1,6 @@ // Copyright 2023 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT + package util import ( @@ -8,7 +9,7 @@ import ( "strings" ) -// Get color as RGB values in 0..255 range from the hex color string (with or without #) +// HexToRBGColor parses color as RGB values in 0..255 range from the hex color string (with or without #) func HexToRBGColor(colorString string) (float64, float64, float64) { hexString := colorString if strings.HasPrefix(colorString, "#") { @@ -35,7 +36,7 @@ func HexToRBGColor(colorString string) (float64, float64, float64) { return r, g, b } -// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance +// GetRelativeLuminance returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance // Keep this in sync with web_src/js/utils/color.js func GetRelativeLuminance(color string) float64 { r, g, b := HexToRBGColor(color) @@ -46,8 +47,8 @@ func UseLightText(backgroundColor string) bool { return GetRelativeLuminance(backgroundColor) < 0.453 } -// Given a background color, returns a black or white foreground color that the highest -// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better. +// ContrastColor returns a black or white foreground color that the highest contrast ratio. +// In the future, the APCA contrast function, or CSS `contrast-color` will be better. // https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42 func ContrastColor(backgroundColor string) string { if UseLightText(backgroundColor) { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index fb591be393..afb860cfc0 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3320,6 +3320,7 @@ self_check.database_collation_case_insensitive = Database is using a collation % self_check.database_inconsistent_collation_columns = Database is using collation %s, but these columns are using mismatched collations. It might cause some unexpected problems. self_check.database_fix_mysql = For MySQL/MariaDB users, you could use the "gitea doctor convert" command to fix the collation problems, or you could also fix the problem by "ALTER ... COLLATE ..." SQLs manually. self_check.database_fix_mssql = For MSSQL users, you could only fix the problem by "ALTER ... COLLATE ..." SQLs manually at the moment. +self_check.location_origin_mismatch = Current URL (%[1]s) doesn't match the URL seen by Gitea (%[2]s). If you are using a reverse proxy, please make sure the "Host" and "X-Forwarded-Proto" headers are set correctly. [action] create_repo = created repository %s @@ -3347,7 +3348,7 @@ mirror_sync_create = synced new reference %[3]s to %[3]s from mirror approve_pull_request = `approved %[3]s#%[2]s` reject_pull_request = `suggested changes for %[3]s#%[2]s` -publish_release = `released "%[4]s" at %[3]s` +publish_release = `released %[4]s at %[3]s` review_dismissed = `dismissed review from %[4]s for %[3]s#%[2]s` review_dismissed_reason = Reason: create_branch = created branch %[3]s in %[4]s @@ -3414,6 +3415,7 @@ error.unit_not_allowed = You are not allowed to access this repository section. title = Packages desc = Manage repository packages. empty = There are no packages yet. +no_metadata = No metadata. empty.documentation = For more information on the package registry, see the documentation. empty.repo = Did you upload a package, but it's not shown here? Go to package settings and link it to this repo. registry.documentation = For more information on the %s registry, see the documentation. @@ -3635,6 +3637,7 @@ runs.pushed_by = pushed by runs.invalid_workflow_helper = Workflow config file is invalid. Please check your config file: %s runs.no_matching_online_runner_helper = No matching online runner with label: %s runs.no_job_without_needs = The workflow must contain at least one job without dependencies. +runs.no_job = The workflow must contain at least one job runs.actor = Actor runs.status = Status runs.actors_no_select = All actors diff --git a/package-lock.json b/package-lock.json index 8e4eeb7fb8..bba4ca5a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@github/text-expander-element": "2.6.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.9.0", + "@silverwind/vue3-calendar-heatmap": "2.0.6", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "6.0.2", "asciinema-player": "3.7.1", @@ -42,7 +43,6 @@ "postcss": "8.4.38", "postcss-loader": "8.1.1", "postcss-nesting": "12.1.2", - "pretty-ms": "9.0.0", "sortablejs": "1.15.2", "swagger-ui-dist": "5.17.2", "tailwindcss": "3.4.3", @@ -58,7 +58,6 @@ "vue-bar-graph": "2.0.0", "vue-chartjs": "5.3.1", "vue-loader": "17.4.2", - "vue3-calendar-heatmap": "2.0.5", "webpack": "5.91.0", "webpack-cli": "5.1.4", "wrap-ansi": "9.0.0" @@ -1627,6 +1626,18 @@ "win32" ] }, + "node_modules/@silverwind/vue3-calendar-heatmap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@silverwind/vue3-calendar-heatmap/-/vue3-calendar-heatmap-2.0.6.tgz", + "integrity": "sha512-efX+nf2GR7EfA7iNgZDeM9Jue5ksglSXvN0C/ja0M1bTmkCpAxKlGJ3vki7wfTPQgX1O0nCfAM62IKqUUEM0cQ==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "tippy.js": "^6.3.7", + "vue": "^3.2.29" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -9170,17 +9181,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9772,20 +9772,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-ms": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", - "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -12226,18 +12212,6 @@ } } }, - "node_modules/vue3-calendar-heatmap": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vue3-calendar-heatmap/-/vue3-calendar-heatmap-2.0.5.tgz", - "integrity": "sha512-qvveNQlTS5Aw7AvRLs0zOyu3uP5iGJlXJAnkrkG2ElDdyQ8H1TJhQ8rL702CROjAg16ezIveUY10nCO7lqZ25w==", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "tippy.js": "^6.3.7", - "vue": "^3.2.29" - } - }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/package.json b/package.json index 142b9bb3ee..107f0c96cf 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@github/text-expander-element": "2.6.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.9.0", + "@silverwind/vue3-calendar-heatmap": "2.0.6", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "6.0.2", "asciinema-player": "3.7.1", @@ -41,7 +42,6 @@ "postcss": "8.4.38", "postcss-loader": "8.1.1", "postcss-nesting": "12.1.2", - "pretty-ms": "9.0.0", "sortablejs": "1.15.2", "swagger-ui-dist": "5.17.2", "tailwindcss": "3.4.3", @@ -57,7 +57,6 @@ "vue-bar-graph": "2.0.0", "vue-chartjs": "5.3.1", "vue-loader": "17.4.2", - "vue3-calendar-heatmap": "2.0.5", "webpack": "5.91.0", "webpack-cli": "5.1.4", "wrap-ansi": "9.0.0" diff --git a/poetry.lock b/poetry.lock index 1533ddc5ec..74536495d2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "click" @@ -318,13 +318,13 @@ files = [ [[package]] name = "tqdm" -version = "4.66.2" +version = "4.66.4" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" files = [ - {file = "tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9"}, - {file = "tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531"}, + {file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"}, + {file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"}, ] [package.dependencies] diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 3e717b8d8f..35e3ee6906 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -71,6 +71,7 @@ import ( "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -184,8 +185,8 @@ type artifactRoutes struct { fs storage.ObjectStorage } -func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix string) string { - uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") + +func (ar artifactRoutes) buildArtifactURL(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string { + uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(ar.prefix, "/") + strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) + "/" + artifactHash + "/" + suffix return uploadURL @@ -224,7 +225,7 @@ func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { // use md5(artifact_name) to create upload url artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name))) resp := getUploadArtifactResponse{ - FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery), + FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "upload"+retentionQuery), } log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL) ctx.JSON(http.StatusOK, resp) @@ -365,7 +366,7 @@ func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName))) item := listArtifactsResponseItem{ Name: art.ArtifactName, - FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "download_url"), + FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"), } items = append(items, item) values[art.ArtifactName] = true @@ -437,7 +438,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { } } if downloadURL == "" { - downloadURL = ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download") + downloadURL = ar.buildArtifactURL(ctx, runID, strconv.FormatInt(artifact.ID, 10), "download") } item := downloadArtifactResponseItem{ Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), @@ -466,14 +467,15 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { log.Error("Error getting artifact: %v", err) ctx.Error(http.StatusInternalServerError, err.Error()) return - } else if !exist { + } + if !exist { log.Error("artifact with ID %d does not exist", artifactID) ctx.Error(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID)) return } if artifact.RunID != runID { - log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) - ctx.Error(http.StatusBadRequest, err.Error()) + log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) + ctx.Error(http.StatusBadRequest) return } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 8300989c75..dde9caf4f2 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -92,6 +92,7 @@ import ( "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -160,9 +161,9 @@ func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, tas return mac.Sum(nil) } -func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string { +func (r artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endp, artifactName string, taskID int64) string { expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST") - uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") + + uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(r.prefix, "/") + "/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID) return uploadURL } @@ -278,7 +279,7 @@ func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { respData := CreateArtifactResponse{ Ok: true, - SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, ctx.ActionTask.ID), + SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID), } r.sendProtbufBody(ctx, &respData) } @@ -454,7 +455,7 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { } } if respData.SignedUrl == "" { - respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, ctx.ActionTask.ID) + respData.SignedUrl = r.buildArtifactURL(ctx, "DownloadArtifact", artifactName, ctx.ActionTask.ID) } r.sendProtbufBody(ctx, &respData) } diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go index 0e99f3deda..521ba910e3 100644 --- a/routers/api/actions/runner/interceptor.go +++ b/routers/api/actions/runner/interceptor.go @@ -23,8 +23,6 @@ import ( const ( uuidHeaderKey = "x-runner-uuid" tokenHeaderKey = "x-runner-token" - // Deprecated: will be removed after Gitea 1.20 released. - versionHeaderKey = "x-runner-version" ) var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc { @@ -35,9 +33,6 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar } uuid := request.Header().Get(uuidHeaderKey) token := request.Header().Get(tokenHeaderKey) - // TODO: version will be removed from request header after Gitea 1.20 released. - // And Gitea will not try to read version from request header - version := request.Header().Get(versionHeaderKey) runner, err := actions_model.GetRunnerByUUID(ctx, uuid) if err != nil { @@ -51,14 +46,6 @@ var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unar } cols := []string{"last_online"} - - // TODO: version will be removed from request header after Gitea 1.20 released. - // And Gitea will not try to read version from request header - version, _ = util.SplitStringAtByteN(version, 64) - if !util.IsEmptyString(version) && runner.Version != version { - runner.Version = version - cols = append(cols, "version") - } runner.LastOnline = timeutil.TimeStampNow() if methodName == "UpdateTask" || methodName == "UpdateLog" { runner.LastActive = timeutil.TimeStampNow() diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index b2f3e7af78..d4078d8af2 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -67,12 +67,6 @@ func (s *Service) Register( } labels := req.Msg.Labels - // TODO: agent_labels should be removed from pb after Gitea 1.20 released. - // Old version runner's agent_labels slice is not empty and labels slice is empty. - // And due to compatibility with older versions, it is temporarily marked as Deprecated in pb, so use `//nolint` here. - if len(req.Msg.AgentLabels) > 0 && len(req.Msg.Labels) == 0 { //nolint:staticcheck - labels = req.Msg.AgentLabels //nolint:staticcheck - } // create new runner name, _ := util.SplitStringAtByteN(req.Msg.Name, 255) diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 2cb16daebc..2a6d44ba08 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -17,6 +17,7 @@ import ( packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" @@ -115,7 +116,9 @@ func apiErrorDefined(ctx *context.Context, err *namedError) { } func apiUnauthorizedError(ctx *context.Context) { - ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`) + // TODO: it doesn't seem quite right but it doesn't really cause problem at the moment. + // container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed, ideally. + ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+httplib.GuessCurrentAppURL(ctx)+`v2/token",service="container_registry",scope="*"`) apiErrorDefined(ctx, errUnauthorized) } diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go index 27f0578db7..cb15eae682 100644 --- a/routers/api/packages/maven/maven.go +++ b/routers/api/packages/maven/maven.go @@ -140,9 +140,7 @@ func serveMavenMetadata(ctx *context.Context, params parameters) { ctx.Resp.Header().Set("Content-Length", strconv.Itoa(len(xmlMetadataWithHeader))) ctx.Resp.Header().Set("Content-Type", contentTypeXML) - if _, err := ctx.Resp.Write(xmlMetadataWithHeader); err != nil { - log.Error("write bytes failed: %v", err) - } + _, _ = ctx.Resp.Write(xmlMetadataWithHeader) } func servePackageFile(ctx *context.Context, params parameters, serveContent bool) { diff --git a/routers/api/v1/org/avatar.go b/routers/api/v1/org/avatar.go index e34c68dfc9..f11eb6c1cd 100644 --- a/routers/api/v1/org/avatar.go +++ b/routers/api/v1/org/avatar.go @@ -46,6 +46,7 @@ func UpdateAvatar(ctx *context.APIContext) { err = user_service.UploadAvatar(ctx, ctx.Org.Organization.AsUser(), content) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + return } ctx.Status(http.StatusNoContent) @@ -72,6 +73,7 @@ func DeleteAvatar(ctx *context.APIContext) { err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + return } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index dfe6d31f74..b91fbc33bf 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -29,7 +29,6 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" - notify_service "code.gitea.io/gitea/services/notify" ) // SearchIssues searches for issues across the repositories that the user has access to @@ -803,12 +802,19 @@ func EditIssue(ctx *context.APIContext) { return } - oldTitle := issue.Title if len(form.Title) > 0 { - issue.Title = form.Title + err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) + return + } } if form.Body != nil { - issue.Content = *form.Body + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } } if form.Ref != nil { err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref) @@ -880,24 +886,14 @@ func EditIssue(ctx *context.APIContext) { return } } - issue.IsClosed = api.StateClosed == api.StateType(*form.State) - } - statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer) - if err != nil { - if issues_model.IsErrDependenciesLeft(err) { - ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", api.StateClosed == api.StateType(*form.State)); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") + return + } + ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) return } - ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err) - return - } - - if titleChanged { - notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) - } - - if statusChangeComment != nil { - notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) } // Refetch from database to assign some automatic values diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index 7a5c6d554d..f5a28e6fa6 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -153,6 +154,8 @@ func CreateIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" @@ -185,7 +188,11 @@ func CreateIssueAttachment(ctx *context.APIContext) { IssueID: issue.ID, }) if err != nil { - ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + } return } diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index 4096cbf07b..77aa7f0400 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/attachment" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" ) @@ -160,6 +161,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" @@ -194,9 +197,14 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { CommentID: comment.ID, }) if err != nil { - ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + } return } + if err := comment.LoadAttachments(ctx); err != nil { ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) return diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index 7d9f85d2aa..413693c5ed 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -5,7 +5,9 @@ package repo import ( + "fmt" "net/http" + "reflect" issues_model "code.gitea.io/gitea/models/issues" api "code.gitea.io/gitea/modules/structs" @@ -317,7 +319,32 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return nil, nil, err } - labels, err := issues_model.GetLabelsByIDs(ctx, form.Labels, "id", "repo_id", "org_id", "name", "exclusive") + var ( + labelIDs []int64 + labelNames []string + ) + for _, label := range form.Labels { + rv := reflect.ValueOf(label) + switch rv.Kind() { + case reflect.Float64: + labelIDs = append(labelIDs, int64(rv.Float())) + case reflect.String: + labelNames = append(labelNames, rv.String()) + } + } + if len(labelIDs) > 0 && len(labelNames) > 0 { + ctx.Error(http.StatusBadRequest, "InvalidLabels", "labels should be an array of strings or integers") + return nil, nil, fmt.Errorf("invalid labels") + } + if len(labelNames) > 0 { + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) + return nil, nil, err + } + } + + labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive") if err != nil { ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) return nil, nil, err diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 2caaa130e8..44db2fe15c 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -175,7 +175,7 @@ func Migrate(ctx *context.APIContext) { Description: opts.Description, OriginalURL: form.CloneAddr, GitServiceType: gitServiceType, - IsPrivate: opts.Private, + IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, }) diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 4129f94ac3..38a32a73c7 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -602,12 +602,19 @@ func EditPullRequest(ctx *context.APIContext) { return } - oldTitle := issue.Title if len(form.Title) > 0 { - issue.Title = form.Title + err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) + return + } } - if len(form.Body) > 0 { - issue.Content = form.Body + if form.Body != nil { + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } } // Update or remove deadline if set @@ -686,24 +693,14 @@ func EditPullRequest(ctx *context.APIContext) { ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") return } - issue.IsClosed = api.StateClosed == api.StateType(*form.State) - } - statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(ctx, issue, ctx.Doer) - if err != nil { - if issues_model.IsErrDependenciesLeft(err) { - ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", api.StateClosed == api.StateType(*form.State)); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") + return + } + ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) return } - ctx.Error(http.StatusInternalServerError, "UpdateIssueByAPI", err) - return - } - - if titleChanged { - notify_service.IssueChangeTitle(ctx, ctx.Doer, issue, oldTitle) - } - - if statusChangeComment != nil { - notify_service.IssueChangeStatus(ctx, ctx.Doer, "", issue, statusChangeComment, issue.IsClosed) } // change pull target branch @@ -884,7 +881,7 @@ func MergePullRequest(ctx *context.APIContext) { } // start with merging by checking - if err := pull_service.CheckPullMergable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { + if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { if errors.Is(err, pull_service.ErrIsClosed) { ctx.NotFound() } else if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) { @@ -893,7 +890,7 @@ func MergePullRequest(ctx *context.APIContext) { ctx.Error(http.StatusMethodNotAllowed, "PR already merged", "") } else if errors.Is(err, pull_service.ErrIsWorkInProgress) { ctx.Error(http.StatusMethodNotAllowed, "PR is a work in progress", "Work in progress PRs cannot be merged") - } else if errors.Is(err, pull_service.ErrNotMergableState) { + } else if errors.Is(err, pull_service.ErrNotMergeableState) { ctx.Error(http.StatusMethodNotAllowed, "PR not in mergeable state", "Please try again later") } else if models.IsErrDisallowedToMerge(err) { ctx.Error(http.StatusMethodNotAllowed, "PR is not ready to be merged", err) diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index f0f3c0bbc7..f92fb86f5c 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -215,6 +215,9 @@ func CreateRelease(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.CreateReleaseOption) if ctx.Repo.Repository.IsEmpty { ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) @@ -246,6 +249,8 @@ func CreateRelease(ctx *context.APIContext) { if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, ""); err != nil { if repo_model.IsErrReleaseAlreadyExist(err) { ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err) + } else if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "ProtectedTagName", err) } else { ctx.Error(http.StatusInternalServerError, "CreateRelease", err) } @@ -386,8 +391,8 @@ func DeleteRelease(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" - // "405": - // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" id := ctx.ParamsInt64(":id") rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) @@ -401,7 +406,7 @@ func DeleteRelease(ctx *context.APIContext) { } if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, false); err != nil { if models.IsErrProtectedTagName(err) { - ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") return } ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go index fec91164a2..f845fad53b 100644 --- a/routers/api/v1/repo/release_tags.go +++ b/routers/api/v1/repo/release_tags.go @@ -92,8 +92,8 @@ func DeleteReleaseByTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" - // "405": - // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" tag := ctx.Params(":tag") @@ -114,7 +114,7 @@ func DeleteReleaseByTag(ctx *context.APIContext) { if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, release, ctx.Doer, false); err != nil { if models.IsErrProtectedTagName(err) { - ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") return } ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 7f35a7fe41..594f2d86f6 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -252,7 +252,7 @@ func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.Cre Gitignores: opt.Gitignores, License: opt.License, Readme: opt.Readme, - IsPrivate: opt.Private, + IsPrivate: opt.Private || setting.Repository.ForcePrivate, AutoInit: opt.AutoInit, DefaultBranch: opt.DefaultBranch, TrustModel: repo_model.ToTrustModel(opt.TrustModel), @@ -364,7 +364,7 @@ func Generate(ctx *context.APIContext) { Name: form.Name, DefaultBranch: form.DefaultBranch, Description: form.Description, - Private: form.Private, + Private: form.Private || setting.Repository.ForcePrivate, GitContent: form.GitContent, Topics: form.Topics, GitHooks: form.GitHooks, @@ -1062,16 +1062,10 @@ func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) e func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error { repo := ctx.Repo.Repository - // only update mirror if interval or enable prune are provided - if opts.MirrorInterval == nil && opts.EnablePrune == nil { - return nil - } - - // these values only make sense if the repo is a mirror + // Skip this update if the repo is not a mirror, do not return error. + // Because reporting errors only makes the logic more complex&fragile, it doesn't really help end users. if !repo.IsMirror { - err := fmt.Errorf("repo is not a mirror, can not change mirror interval") - ctx.Error(http.StatusUnprocessableEntity, err.Error(), err) - return err + return nil } // get the mirror from the repo diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index a6908f3615..8577a0e896 100644 --- a/routers/api/v1/repo/tag.go +++ b/routers/api/v1/repo/tag.go @@ -184,6 +184,8 @@ func CreateTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" form := web.GetForm(ctx).(*api.CreateTagOption) @@ -205,7 +207,7 @@ func CreateTag(ctx *context.APIContext) { return } if models.IsErrProtectedTagName(err) { - ctx.Error(http.StatusMethodNotAllowed, "CreateNewTag", "user not allowed to create protected tag") + ctx.Error(http.StatusUnprocessableEntity, "CreateNewTag", "user not allowed to create protected tag") return } @@ -253,6 +255,8 @@ func DeleteTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" tagName := ctx.Params("*") @@ -274,7 +278,7 @@ func DeleteTag(ctx *context.APIContext) { if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, tag, ctx.Doer, true); err != nil { if models.IsErrProtectedTagName(err) { - ctx.Error(http.StatusMethodNotAllowed, "delTag", "user not allowed to delete protected tag") + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") return } ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) diff --git a/routers/api/v1/user/avatar.go b/routers/api/v1/user/avatar.go index f912296228..30ccb63587 100644 --- a/routers/api/v1/user/avatar.go +++ b/routers/api/v1/user/avatar.go @@ -39,6 +39,7 @@ func UpdateAvatar(ctx *context.APIContext) { err = user_service.UploadAvatar(ctx, ctx.Doer, content) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + return } ctx.Status(http.StatusNoContent) @@ -57,6 +58,7 @@ func DeleteAvatar(ctx *context.APIContext) { err := user_service.DeleteAvatar(ctx, ctx.Doer) if err != nil { ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + return } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index 81f8e0f3fe..d0264d6b5a 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -6,10 +6,8 @@ package user import ( "net/http" - "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" @@ -44,7 +42,7 @@ func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) return } - if ctx.IsSigned && ctx.Doer.IsAdmin || permission.UnitAccessMode(unit_model.TypeCode) >= perm.AccessModeRead { + if ctx.IsSigned && ctx.Doer.IsAdmin || permission.HasAnyUnitAccess() { apiRepos = append(apiRepos, convert.ToRepo(ctx, repos[i], permission)) } } diff --git a/routers/common/middleware.go b/routers/common/middleware.go index c7c75fb099..8b661993bb 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -4,11 +4,13 @@ package common import ( + go_context "context" "fmt" "net/http" "strings" "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" @@ -34,6 +36,7 @@ func ProtocolMiddlewares() (handlers []any) { } }() req = req.WithContext(middleware.WithContextData(req.Context())) + req = req.WithContext(go_context.WithValue(req.Context(), httplib.RequestContextKey, req)) next.ServeHTTP(resp, req) }) }) diff --git a/routers/common/redirect.go b/routers/common/redirect.go index 34044e814b..d64f74ec82 100644 --- a/routers/common/redirect.go +++ b/routers/common/redirect.go @@ -17,7 +17,7 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) { // The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2", // then frontend needs this delegate to redirect to the new location with hash correctly. redirect := req.PostFormValue("redirect") - if !httplib.IsCurrentGiteaSiteURL(redirect) { + if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) { resp.WriteHeader(http.StatusBadRequest) return } diff --git a/routers/init.go b/routers/init.go index 030ef3c740..56c95cd1ca 100644 --- a/routers/init.go +++ b/routers/init.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/system" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/routing" actions_router "code.gitea.io/gitea/routers/api/actions" @@ -112,7 +113,10 @@ func InitWebInstallPage(ctx context.Context) { // InitWebInstalled is for global installed configuration. func InitWebInstalled(ctx context.Context) { mustInitCtx(ctx, git.InitFull) - log.Info("Git version: %s (home: %s)", git.VersionInfo(), git.HomeDir()) + log.Info("Git version: %s (home: %s)", git.DefaultFeatures().VersionInfo(), git.HomeDir()) + if !git.DefaultFeatures().SupportHashSha256 { + log.Warn("sha256 hash support is disabled - requires Git >= 2.42." + util.Iif(git.DefaultFeatures().UsingGogit, " Gogit is currently unsupported.", "")) + } // Setup i18n translation.InitLocales(ctx) diff --git a/routers/install/install.go b/routers/install/install.go index 9c6a8849b6..fde8b37ed5 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -481,6 +481,17 @@ func SubmitInstall(ctx *context.Context) { cfg.Section("security").Key("INTERNAL_TOKEN").SetValue(internalToken) } + // FIXME: at the moment, no matter oauth2 is enabled or not, it must generate a "oauth2 JWT_SECRET" + // see the "loadOAuth2From" in "setting/oauth2.go" + if !cfg.Section("oauth2").HasKey("JWT_SECRET") && !cfg.Section("oauth2").HasKey("JWT_SECRET_URI") { + _, jwtSecretBase64, err := generate.NewJwtSecretWithBase64() + if err != nil { + ctx.RenderWithErr(ctx.Tr("install.secret_key_failed", err), tplInstall, &form) + return + } + cfg.Section("oauth2").Key("JWT_SECRET").SetValue(jwtSecretBase64) + } + // if there is already a SECRET_KEY, we should not overwrite it, otherwise the encrypted data will not be able to be decrypted if setting.SecretKey == "" { var secretKey string diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 769a68970d..b6699e465f 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -4,20 +4,25 @@ package private import ( + "context" "fmt" "net/http" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" + pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" + timeutil "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" gitea_context "code.gitea.io/gitea/services/context" @@ -160,6 +165,14 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { } } + // handle pull request merging, a pull request action should push at least 1 commit + if opts.PushTrigger == repo_module.PushTriggerPRMergeToBase { + handlePullRequestMerging(ctx, opts, ownerName, repoName, updates) + if ctx.Written() { + return + } + } + isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate) isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) // Handle Push Options @@ -174,7 +187,7 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { wasEmpty = repo.IsEmpty } - pusher, err := user_model.GetUserByID(ctx, opts.UserID) + pusher, err := loadContextCacheUser(ctx, opts.UserID) if err != nil { log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ @@ -309,3 +322,52 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { RepoWasEmpty: wasEmpty, }) } + +func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) { + return cache.GetWithContextCache(ctx, "hook_post_receive_user", id, func() (*user_model.User, error) { + return user_model.GetUserByID(ctx, id) + }) +} + +// handlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit +func handlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, ownerName, repoName string, updates []*repo_module.PushUpdateOptions) { + if len(updates) == 0 { + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{ + Err: fmt.Sprintf("Pushing a merged PR (pr:%d) no commits pushed ", opts.PullRequestID), + }) + return + } + + pr, err := issues_model.GetPullRequestByID(ctx, opts.PullRequestID) + if err != nil { + log.Error("GetPullRequestByID[%d]: %v", opts.PullRequestID, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "GetPullRequestByID failed"}) + return + } + + pusher, err := loadContextCacheUser(ctx, opts.UserID) + if err != nil { + log.Error("Failed to Update: %s/%s Error: %v", ownerName, repoName, err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Load pusher user failed"}) + return + } + + pr.MergedCommitID = updates[len(updates)-1].NewCommitID + pr.MergedUnix = timeutil.TimeStampNow() + pr.Merger = pusher + pr.MergerID = pusher.ID + err = db.WithTx(ctx, func(ctx context.Context) error { + // Removing an auto merge pull and ignore if not exist + if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) { + return fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", opts.PullRequestID, err) + } + if _, err := pr.SetMerged(ctx); err != nil { + return fmt.Errorf("SetMerged failed: %s/%s Error: %v", ownerName, repoName, err) + } + return nil + }) + if err != nil { + log.Error("Failed to update PR to merged: %v", err) + ctx.JSON(http.StatusInternalServerError, private.HookPostReceiveResult{Err: "Failed to update PR to merged"}) + } +} diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go new file mode 100644 index 0000000000..658557d3cf --- /dev/null +++ b/routers/private/hook_post_receive_test.go @@ -0,0 +1,49 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package private + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/private" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestHandlePullRequestMerging(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + pr, err := issues_model.GetUnmergedPullRequest(db.DefaultContext, 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub) + assert.NoError(t, err) + assert.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + err = pull_model.ScheduleAutoMerge(db.DefaultContext, user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr") + assert.NoError(t, err) + + autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID}) + + ctx, resp := contexttest.MockPrivateContext(t, "/") + handlePullRequestMerging(ctx, &private.HookOptions{ + PullRequestID: pr.ID, + UserID: 2, + }, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, []*repo_module.PushUpdateOptions{ + {NewCommitID: "01234567"}, + }) + assert.Equal(t, 0, len(resp.Body.String())) + pr, err = issues_model.GetPullRequestByID(db.DefaultContext, pr.ID) + assert.NoError(t, err) + assert.True(t, pr.HasMerged) + assert.EqualValues(t, "01234567", pr.MergedCommitID) + + unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{ID: autoMerge.ID}) +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index f35eb77d42..efdbc114ef 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -122,7 +122,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName) case refFullName.IsTag(): preReceiveTag(ourCtx, oldCommitID, newCommitID, refFullName) - case git.DefaultFeatures.SupportProcReceive && refFullName.IsFor(): + case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor(): preReceiveFor(ourCtx, oldCommitID, newCommitID, refFullName) default: ourCtx.AssertCanWriteCode() diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go index cee3bbdd12..efb3f5831e 100644 --- a/routers/private/hook_proc_receive.go +++ b/routers/private/hook_proc_receive.go @@ -18,7 +18,7 @@ import ( // HookProcReceive proc-receive hook - only handles agit Proc-Receive requests at present func HookProcReceive(ctx *gitea_context.PrivateContext) { opts := web.GetForm(ctx).(*private.HookOptions) - if !git.DefaultFeatures.SupportProcReceive { + if !git.DefaultFeatures().SupportProcReceive { ctx.Status(http.StatusNotFound) return } diff --git a/routers/private/serv.go b/routers/private/serv.go index 85368a0aed..1c309865d7 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -297,7 +297,7 @@ func ServCommand(ctx *context.PrivateContext) { } } else { // Because of the special ref "refs/for" we will need to delay write permission check - if git.DefaultFeatures.SupportProcReceive && unitType == unit.TypeCode { + if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode { mode = perm.AccessModeRead } diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index e6585d8833..5edf58377f 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -9,12 +9,14 @@ import ( "net/http" "runtime" "sort" + "strings" "time" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -223,6 +225,16 @@ func SelfCheck(ctx *context.Context) { ctx.HTML(http.StatusOK, tplSelfCheck) } +func SelfCheckPost(ctx *context.Context) { + var problems []string + frontendAppURL := ctx.FormString("location_origin") + setting.AppSubURL + "/" + ctxAppURL := httplib.GuessCurrentAppURL(ctx) + if !strings.HasPrefix(ctxAppURL, frontendAppURL) { + problems = append(problems, ctx.Locale.TrString("admin.self_check.location_origin_mismatch", frontendAppURL, ctxAppURL)) + } + ctx.JSON(http.StatusOK, map[string]any{"problems": problems}) +} + func CronTasks(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.monitor.cron") ctx.Data["PageIsAdminMonitorCron"] = true diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go index 2b65ab3ea3..6c38f0b509 100644 --- a/routers/web/admin/admin_test.go +++ b/routers/web/admin/admin_test.go @@ -4,8 +4,14 @@ package admin import ( + "net/http" "testing" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + "github.com/stretchr/testify/assert" ) @@ -66,3 +72,21 @@ func TestShadowPassword(t *testing.T) { assert.EqualValues(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) } } + +func TestSelfCheckPost(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "http://config/sub/")() + defer test.MockVariableValue(&setting.AppSubURL, "/sub")() + + ctx, resp := contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend") + SelfCheckPost(ctx) + assert.EqualValues(t, http.StatusOK, resp.Code) + + data := struct { + Problems []string `json:"problems"` + }{} + err := json.Unmarshal(resp.Body.Bytes(), &data) + assert.NoError(t, err) + assert.Equal(t, []string{ + ctx.Locale.TrString("admin.self_check.location_origin_mismatch", "http://frontend/sub/", "http://config/sub/"), + }, data.Problems) +} diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 48f80dbbf1..fd8c73b62d 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -112,7 +112,7 @@ func Config(ctx *context.Context) { ctx.Data["OfflineMode"] = setting.OfflineMode ctx.Data["RunUser"] = setting.RunUser ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) - ctx.Data["GitVersion"] = git.VersionInfo() + ctx.Data["GitVersion"] = git.DefaultFeatures().VersionInfo() ctx.Data["AppDataPath"] = setting.AppDataPath ctx.Data["RepoRootPath"] = setting.RepoRootPath diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 7c873796fe..4083d64226 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -368,7 +368,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe return setting.AppSubURL + "/" } - if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" && httplib.IsCurrentGiteaSiteURL(redirectTo) { + if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" && httplib.IsCurrentGiteaSiteURL(ctx, redirectTo) { middleware.DeleteRedirectToCookie(ctx.Resp) if obeyRedirect { ctx.RedirectToCurrentSite(redirectTo) diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index c9cb7859cd..b337b6b156 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -470,8 +470,9 @@ func AuthorizeOAuth(ctx *context.Context) { return } - // Redirect if user already granted access - if grant != nil { + // Redirect if user already granted access and the application is confidential. + // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2 + if app.ConfidentialClient && grant != nil { code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) if err != nil { handleServerError(ctx, form.State, form.RedirectURI) @@ -540,20 +541,45 @@ func GrantApplicationOAuth(ctx *context.Context) { ctx.Error(http.StatusBadRequest) return } + + if !form.Granted { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "the request is denied", + ErrorCode: ErrorCodeAccessDenied, + }, form.RedirectURI) + return + } + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) if err != nil { ctx.ServerError("GetOAuth2ApplicationByClientID", err) return } - grant, err := app.CreateGrant(ctx, ctx.Doer.ID, form.Scope) + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) if err != nil { + handleServerError(ctx, form.State, form.RedirectURI) + return + } + if grant == nil { + grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + } else if grant.Scope != form.Scope { handleAuthorizeError(ctx, AuthorizeError{ State: form.State, - ErrorDescription: "cannot create grant for user", + ErrorDescription: "a grant exists with different scope", ErrorCode: ErrorCodeServerError, }, form.RedirectURI) return } + if len(form.Nonce) > 0 { err := grant.SetNonce(ctx, form.Nonce) if err != nil { diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index 66477a255c..764c226d40 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -71,9 +71,9 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { case "leastupdate": orderBy = db.SearchOrderByLeastUpdated case "reversealphabetically": - orderBy = db.SearchOrderByAlphabeticallyReverse + orderBy = "owner_name DESC, name DESC" case "alphabetically": - orderBy = db.SearchOrderByAlphabetically + orderBy = "owner_name ASC, name ASC" case "reversesize": orderBy = db.SearchOrderBySizeReverse case "size": diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index ac5496ce91..caaca7f521 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -15,7 +15,7 @@ import ( ) func SSHInfo(rw http.ResponseWriter, req *http.Request) { - if !git.DefaultFeatures.SupportProcReceive { + if !git.DefaultFeatures().SupportProcReceive { rw.WriteHeader(http.StatusNotFound) return } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 7f78d1c830..50effbe963 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -390,74 +389,6 @@ func ViewProject(ctx *context.Context) { ctx.HTML(http.StatusOK, tplProjectsView) } -func getActionIssues(ctx *context.Context) issues_model.IssueList { - commaSeparatedIssueIDs := ctx.FormString("issue_ids") - if len(commaSeparatedIssueIDs) == 0 { - return nil - } - issueIDs := make([]int64, 0, 10) - for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { - issueID, err := strconv.ParseInt(stringIssueID, 10, 64) - if err != nil { - ctx.ServerError("ParseInt", err) - return nil - } - issueIDs = append(issueIDs, issueID) - } - issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) - if err != nil { - ctx.ServerError("GetIssuesByIDs", err) - return nil - } - // Check access rights for all issues - issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) - prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) - for _, issue := range issues { - if issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) - return nil - } - if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { - ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) - return nil - } - if err = issue.LoadAttributes(ctx); err != nil { - ctx.ServerError("LoadAttributes", err) - return nil - } - } - return issues -} - -// UpdateIssueProject change an issue's project -func UpdateIssueProject(ctx *context.Context) { - issues := getActionIssues(ctx) - if ctx.Written() { - return - } - - if err := issues.LoadProjects(ctx); err != nil { - ctx.ServerError("LoadProjects", err) - return - } - - projectID := ctx.FormInt64("id") - for _, issue := range issues { - if issue.Project != nil { - if issue.Project.ID == projectID { - continue - } - } - - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { - ctx.ServerError("ChangeProjectAssign", err) - return - } - } - - ctx.JSONOK() -} - // DeleteProjectBoard allows for the deletion of a project board func DeleteProjectBoard(ctx *context.Context) { if ctx.Doer == nil { diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 6059ad1414..a0f03ec7e9 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -107,7 +107,12 @@ func List(ctx *context.Context) { // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. hasJobWithoutNeeds := false // Check whether have matching runner and a job without "needs" + emptyJobsNumber := 0 for _, j := range wf.Jobs { + if j == nil { + emptyJobsNumber++ + continue + } if !hasJobWithoutNeeds && len(j.Needs()) == 0 { hasJobWithoutNeeds = true } @@ -131,6 +136,9 @@ func List(ctx *context.Context) { if !hasJobWithoutNeeds { workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") } + if emptyJobsNumber == len(wf.Jobs) { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") + } workflows = append(workflows, workflow) } } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 3909a64be6..12909bddd5 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -504,7 +504,7 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions return nil, nil } if len(jobs) == 0 { - ctx.Error(http.StatusNotFound, err.Error()) + ctx.Error(http.StatusNotFound) return nil, nil } diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 8c0fee71a0..818dc4d50f 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -931,7 +931,7 @@ func ExcerptBlob(ctx *context.Context) { } } ctx.Data["section"] = section - ctx.Data["FileNameHash"] = base.EncodeSha1(filePath) + ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath) ctx.Data["AfterCommitID"] = commitID ctx.Data["Anchor"] = anchor ctx.HTML(http.StatusOK, tplBlobExcerpt) diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 474f7ff1da..474d7503e4 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -419,11 +419,9 @@ func DiffPreviewPost(ctx *context.Context) { return } - if diff.NumFiles == 0 { - ctx.PlainText(http.StatusOK, ctx.Locale.TrString("repo.editor.no_changes_to_show")) - return + if diff.NumFiles != 0 { + ctx.Data["File"] = diff.Files[0] } - ctx.Data["File"] = diff.Files[0] ctx.HTML(http.StatusOK, tplEditDiffPreview) } diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 8fb6d93068..f0579b56ea 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -183,7 +183,7 @@ func httpBase(ctx *context.Context) *serviceHandler { if repoExist { // Because of special ref "refs/for" .. , need delay write permission check - if git.DefaultFeatures.SupportProcReceive { + if git.DefaultFeatures().SupportProcReceive { accessMode = perm.AccessModeRead } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index de6ef9e93b..0c8363a168 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2177,7 +2177,10 @@ func GetIssueInfo(ctx *context.Context) { } } - ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue)) + ctx.JSON(http.StatusOK, map[string]any{ + "convertedIssue": convert.ToIssue(ctx, ctx.Doer, issue), + "renderedLabels": templates.RenderLabels(ctx, ctx.Locale, issue.Labels, ctx.Repo.RepoLink, issue), + }) } // UpdateIssueTitle change issue's title diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 9b765e89e8..6186ee150c 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -383,17 +384,21 @@ func UpdateIssueProject(ctx *context.Context) { ctx.ServerError("LoadProjects", err) return } + if _, err := issues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadProjects", err) + return + } projectID := ctx.FormInt64("id") for _, issue := range issues { - if issue.Project != nil { - if issue.Project.ID == projectID { + if issue.Project != nil && issue.Project.ID == projectID { + continue + } + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if errors.Is(err, util.ErrPermissionDenied) { continue } - } - - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { - ctx.ServerError("ChangeProjectAssign", err) + ctx.ServerError("IssueAssignOrRemoveProject", err) return } } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 7f131f2e98..92e0a1674e 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -862,7 +862,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } if pull.HeadRepo != nil { - ctx.Data["SourcePath"] = pull.HeadRepo.Link() + "/src/branch/" + util.PathEscapeSegments(pull.HeadBranch) + ctx.Data["SourcePath"] = pull.HeadRepo.Link() + "/src/commit/" + endCommitID if !pull.HasMerged && ctx.Doer != nil { perm, err := access_model.GetUserRepoPermission(ctx, pull.HeadRepo, ctx.Doer) @@ -1007,7 +1007,7 @@ func MergePullRequest(ctx *context.Context) { } // start with merging by checking - if err := pull_service.CheckPullMergable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { + if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { switch { case errors.Is(err, pull_service.ErrIsClosed): if issue.IsPull { @@ -1021,7 +1021,7 @@ func MergePullRequest(ctx *context.Context) { ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) case errors.Is(err, pull_service.ErrIsWorkInProgress): ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip")) - case errors.Is(err, pull_service.ErrNotMergableState): + case errors.Is(err, pull_service.ErrNotMergeableState): ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) case models.IsErrDisallowedToMerge(err): ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) @@ -1329,14 +1329,12 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - if projectID > 0 { - if !ctx.Repo.CanWrite(unit.TypeProjects) { - ctx.Error(http.StatusBadRequest, "user hasn't the permission to write to projects") - return - } - if err := issues_model.ChangeProjectAssign(ctx, pullIssue, ctx.Doer, projectID); err != nil { - ctx.ServerError("ChangeProjectAssign", err) - return + if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { + if !errors.Is(err, util.ErrPermissionDenied) { + ctx.ServerError("IssueAssignOrRemoveProject", err) + return + } } } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 4e448933c7..71c582b5f9 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -180,7 +180,7 @@ func Create(ctx *context.Context) { ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() - ctx.Data["SupportedObjectFormats"] = git.SupportedObjectFormats + ctx.Data["SupportedObjectFormats"] = git.DefaultFeatures().SupportedObjectFormats ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat ctx.HTML(http.StatusOK, tplCreate) @@ -248,7 +248,7 @@ func CreatePost(ctx *context.Context) { opts := repo_service.GenerateRepoOptions{ Name: form.RepoName, Description: form.Description, - Private: form.Private, + Private: form.Private || setting.Repository.ForcePrivate, GitContent: form.GitContent, Topics: form.Topics, GitHooks: form.GitHooks, diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index 46f0208453..920a865555 100644 --- a/routers/web/repo/search.go +++ b/routers/web/repo/search.go @@ -17,6 +17,16 @@ import ( const tplSearch base.TplName = "repo/search" +func indexSettingToGitGrepPathspecList() (list []string) { + for _, expr := range setting.Indexer.IncludePatterns { + list = append(list, ":(glob)"+expr.PatternString()) + } + for _, expr := range setting.Indexer.ExcludePatterns { + list = append(list, ":(glob,exclude)"+expr.PatternString()) + } + return list +} + // Search render repository search page func Search(ctx *context.Context) { language := ctx.FormTrim("l") @@ -28,6 +38,7 @@ func Search(ctx *context.Context) { ctx.Data["Language"] = language ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["PageIsViewCode"] = true + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled if keyword == "" { ctx.HTML(http.StatusOK, tplSearch) @@ -64,8 +75,14 @@ func Search(ctx *context.Context) { ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) } } else { - res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ContextLineNumber: 3, IsFuzzy: isFuzzy}) + res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ + ContextLineNumber: 1, + IsFuzzy: isFuzzy, + RefName: git.RefNameFromBranch(ctx.Repo.BranchName).String(), // BranchName should be default branch or the first existing branch + PathspecList: indexSettingToGitGrepPathspecList(), + }) if err != nil { + // TODO: if no branch exists, it reports: exit status 128, fatal: this operation must be run in a work tree. ctx.ServerError("GrepSearch", err) return } @@ -86,7 +103,6 @@ func Search(ctx *context.Context) { } } - ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["Repo"] = ctx.Repo.Repository ctx.Data["SearchResults"] = searchResults ctx.Data["SearchResultLanguages"] = searchResultLanguages diff --git a/routers/web/repo/search_test.go b/routers/web/repo/search_test.go new file mode 100644 index 0000000000..33a1610384 --- /dev/null +++ b/routers/web/repo/search_test.go @@ -0,0 +1,19 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestIndexSettingToGitGrepPathspecList(t *testing.T) { + defer test.MockVariableValue(&setting.Indexer.IncludePatterns, setting.IndexerGlobFromString("a"))() + defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("b"))() + assert.Equal(t, []string{":(glob)a", ":(glob,exclude)b"}, indexSettingToGitGrepPathspecList()) +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index b55e259e4b..1e0349cdee 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -65,7 +65,7 @@ func SettingsCtxData(ctx *context.Context) { signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) ctx.Data["SigningKeyAvailable"] = len(signing) > 0 ctx.Data["SigningSettings"] = setting.Repository.Signing - ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled if ctx.Doer.IsAdmin { if setting.Indexer.RepoIndexerEnabled { @@ -110,7 +110,7 @@ func SettingsPost(ctx *context.Context) { signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) ctx.Data["SigningKeyAvailable"] = len(signing) > 0 ctx.Data["SigningSettings"] = setting.Repository.Signing - ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled repo := ctx.Repo.Repository @@ -789,6 +789,7 @@ func SettingsPost(ctx *context.Context) { ctx.Repo.GitRepo = nil } + oldFullname := repo.FullName() if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { if repo_model.IsErrRepoAlreadyExist(err) { ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) @@ -803,8 +804,13 @@ func SettingsPost(ctx *context.Context) { return } - log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) - ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) + if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { + log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) + } else { + log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) + } ctx.Redirect(repo.Link() + "/settings") case "cancel_transfer": diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index e4e6201c24..e1498c0d58 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -29,6 +29,7 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issue_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -1027,15 +1028,26 @@ func renderHomeCode(ctx *context.Context) { return } - showRecentlyPushedNewBranches := true - if ctx.Repo.Repository.IsMirror || - !ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) { - showRecentlyPushedNewBranches = false + opts := &git_model.FindRecentlyPushedNewBranchesOptions{ + Repo: ctx.Repo.Repository, + BaseRepo: ctx.Repo.Repository, } - if showRecentlyPushedNewBranches { - ctx.Data["RecentlyPushedNewBranches"], err = git_model.FindRecentlyPushedNewBranches(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID, ctx.Repo.Repository.DefaultBranch) + if ctx.Repo.Repository.IsFork { + opts.BaseRepo = ctx.Repo.Repository.BaseRepo + } + + baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !opts.Repo.IsMirror && !opts.BaseRepo.IsMirror && + opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) && + baseRepoPerm.CanRead(unit_model.TypePullRequests) { + ctx.Data["RecentlyPushedNewBranches"], err = git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts) if err != nil { - ctx.ServerError("GetRecentlyPushedBranches", err) + ctx.ServerError("FindRecentlyPushedNewBranches", err) return } } diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go new file mode 100644 index 0000000000..599842ea9e --- /dev/null +++ b/routers/web/shared/project/column.go @@ -0,0 +1,48 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/web.go b/routers/web/web.go index 9a6687059b..194a67bf03 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -37,6 +37,7 @@ import ( "code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/routers/web/repo/actions" repo_setting "code.gitea.io/gitea/routers/web/repo/setting" + "code.gitea.io/gitea/routers/web/shared/project" "code.gitea.io/gitea/routers/web/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" "code.gitea.io/gitea/routers/web/user/setting/security" @@ -54,7 +55,7 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -const GzipMinSize = 1400 // min size to compress for the body size of response +var GzipMinSize = 1400 // min size to compress for the body size of response // optionsCorsHandler return a http handler which sets CORS options if enabled by config, it blocks non-CORS OPTIONS requests. func optionsCorsHandler() func(next http.Handler) http.Handler { @@ -97,14 +98,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { // The Session plugin is expected to be executed second, in order to skip authentication // for users that have already signed in. func buildAuthGroup() *auth_service.Group { - group := auth_service.NewGroup( - &auth_service.OAuth2{}, // FIXME: this should be removed and only applied in download and oauth related routers - &auth_service.Basic{}, // FIXME: this should be removed and only applied in download and git/lfs routers - &auth_service.Session{}, - ) + group := auth_service.NewGroup() + group.Add(&auth_service.OAuth2{}) // FIXME: this should be removed and only applied in download and oauth related routers + group.Add(&auth_service.Basic{}) // FIXME: this should be removed and only applied in download and git/lfs routers + if setting.Service.EnableReverseProxyAuth { - group.Add(&auth_service.ReverseProxy{}) + group.Add(&auth_service.ReverseProxy{}) // reverseproxy should before Session, otherwise the header will be ignored if user has login } + group.Add(&auth_service.Session{}) if setting.IsWindows && auth_model.IsSSPIEnabled(db.DefaultContext) { group.Add(&auth_service.SSPI{}) // it MUST be the last, see the comment of SSPI @@ -685,6 +686,7 @@ func registerRoutes(m *web.Route) { m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost) m.Get("/self_check", admin.SelfCheck) + m.Post("/self_check", admin.SelfCheckPost) m.Group("/config", func() { m.Get("", admin.Config) @@ -999,6 +1001,7 @@ func registerRoutes(m *web.Route) { m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) m.Group("/{id}", func() { m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) + m.Post("/move", project.MoveColumns) m.Post("/delete", org.DeleteProject) m.Get("/edit", org.RenderEditProject) @@ -1354,6 +1357,7 @@ func registerRoutes(m *web.Route) { m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) m.Group("/{id}", func() { m.Post("", web.Bind(forms.EditProjectBoardForm{}), repo.AddBoardToProjectPost) + m.Post("/move", project.MoveColumns) m.Post("/delete", repo.DeleteProject) m.Get("/edit", repo.RenderEditProject) diff --git a/services/automerge/automerge.go b/services/automerge/automerge.go index bd427bef9f..10f3c28d56 100644 --- a/services/automerge/automerge.go +++ b/services/automerge/automerge.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" + notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -30,6 +31,8 @@ var prAutoMergeQueue *queue.WorkerPoolQueue[string] // Init runs the task queue to that handles auto merges func Init() error { + notify_service.RegisterNotifier(NewNotifier()) + prAutoMergeQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_auto_merge", handler) if prAutoMergeQueue == nil { return fmt.Errorf("unable to create pr_auto_merge queue") @@ -47,7 +50,7 @@ func handler(items ...string) []string { log.Error("could not parse data from pr_auto_merge queue (%v): %v", s, err) continue } - handlePull(id, sha) + handlePullRequestAutoMerge(id, sha) } return nil } @@ -62,16 +65,6 @@ func addToQueue(pr *issues_model.PullRequest, sha string) { // ScheduleAutoMerge if schedule is false and no error, pull can be merged directly func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string) (scheduled bool, err error) { err = db.WithTx(ctx, func(ctx context.Context) error { - lastCommitStatus, err := pull_service.GetPullRequestCommitStatusState(ctx, pull) - if err != nil { - return err - } - - // we don't need to schedule - if lastCommitStatus.IsSuccess() { - return nil - } - if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message); err != nil { return err } @@ -95,8 +88,8 @@ func RemoveScheduledAutoMerge(ctx context.Context, doer *user_model.User, pull * }) } -// MergeScheduledPullRequest merges a previously scheduled pull request when all checks succeeded -func MergeScheduledPullRequest(ctx context.Context, sha string, repo *repo_model.Repository) error { +// StartPRCheckAndAutoMergeBySHA start an automerge check and auto merge task for all pull requests of repository and SHA +func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_model.Repository) error { pulls, err := getPullRequestsByHeadSHA(ctx, sha, repo, func(pr *issues_model.PullRequest) bool { return !pr.HasMerged && pr.CanAutoMerge() }) @@ -111,6 +104,32 @@ func MergeScheduledPullRequest(ctx context.Context, sha string, repo *repo_model return nil } +// StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request +func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) { + if pull == nil || pull.HasMerged || !pull.CanAutoMerge() { + return + } + + if err := pull.LoadBaseRepo(ctx); err != nil { + log.Error("LoadBaseRepo: %v", err) + return + } + + gitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo) + if err != nil { + log.Error("OpenRepository: %v", err) + return + } + defer gitRepo.Close() + commitID, err := gitRepo.GetRefCommitID(pull.GetGitRefName()) + if err != nil { + log.Error("GetRefCommitID: %v", err) + return + } + + addToQueue(pull, commitID) +} + func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model.Repository, filter func(*issues_model.PullRequest) bool) (map[int64]*issues_model.PullRequest, error) { gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { @@ -161,7 +180,8 @@ func getPullRequestsByHeadSHA(ctx context.Context, sha string, repo *repo_model. return pulls, nil } -func handlePull(pullID int64, sha string) { +// handlePullRequestAutoMerge merge the pull request if all checks are successful +func handlePullRequestAutoMerge(pullID int64, sha string) { ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("Handle AutoMerge of PR[%d] with sha[%s]", pullID, sha)) defer finished() @@ -182,24 +202,50 @@ func handlePull(pullID int64, sha string) { return } + if err = pr.LoadBaseRepo(ctx); err != nil { + log.Error("%-v LoadBaseRepo: %v", pr, err) + return + } + + // check the sha is the same as pull request head commit id + baseGitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + log.Error("OpenRepository: %v", err) + return + } + defer baseGitRepo.Close() + + headCommitID, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("GetRefCommitID: %v", err) + return + } + if headCommitID != sha { + log.Warn("Head commit id of auto merge %-v does not match sha [%s], it may means the head branch has been updated. Just ignore this request because a new request expected in the queue", pr, sha) + return + } + // Get all checks for this pr // We get the latest sha commit hash again to handle the case where the check of a previous push // did not succeed or was not finished yet. - if err = pr.LoadHeadRepo(ctx); err != nil { log.Error("%-v LoadHeadRepo: %v", pr, err) return } - headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo) - if err != nil { - log.Error("OpenRepository %-v: %v", pr.HeadRepo, err) - return + var headGitRepo *git.Repository + if pr.BaseRepoID == pr.HeadRepoID { + headGitRepo = baseGitRepo + } else { + headGitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) + if err != nil { + log.Error("OpenRepository %-v: %v", pr.HeadRepo, err) + return + } + defer headGitRepo.Close() } - defer headGitRepo.Close() headBranchExist := headGitRepo.IsBranchExist(pr.HeadBranch) - if pr.HeadRepo == nil || !headBranchExist { log.Warn("Head branch of auto merge %-v does not exist [HeadRepoID: %d, Branch: %s]", pr, pr.HeadRepoID, pr.HeadBranch) return @@ -229,34 +275,20 @@ func handlePull(pullID int64, sha string) { return } - if err := pull_service.CheckPullMergable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false); err != nil { + if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false); err != nil { if errors.Is(pull_service.ErrUserNotAllowedToMerge, err) { log.Info("%-v was scheduled to automerge by an unauthorized user", pr) return } - log.Error("%-v CheckPullMergable: %v", pr, err) + log.Error("%-v CheckPullMergeable: %v", pr, err) return } - var baseGitRepo *git.Repository - if pr.BaseRepoID == pr.HeadRepoID { - baseGitRepo = headGitRepo - } else { - if err = pr.LoadBaseRepo(ctx); err != nil { - log.Error("%-v LoadBaseRepo: %v", pr, err) - return - } - - baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo) - if err != nil { - log.Error("OpenRepository %-v: %v", pr.BaseRepo, err) - return - } - defer baseGitRepo.Close() - } - if err := pull_service.Merge(ctx, pr, doer, baseGitRepo, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil { log.Error("pull_service.Merge: %v", err) + // FIXME: if merge failed, we should display some error message to the pull request page. + // The resolution is add a new column on automerge table named `error_message` to store the error message and displayed + // on the pull request page. But this should not be finished in a bug fix PR which will be backport to release branch. return } } diff --git a/services/automerge/notify.go b/services/automerge/notify.go new file mode 100644 index 0000000000..cb078214f6 --- /dev/null +++ b/services/automerge/notify.go @@ -0,0 +1,46 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package automerge + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +type automergeNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &automergeNotifier{} + +// NewNotifier create a new automergeNotifier notifier +func NewNotifier() notify_service.Notifier { + return &automergeNotifier{} +} + +func (n *automergeNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { + // as a missing / blocking reviews could have blocked a pending automerge let's recheck + if review.Type == issues_model.ReviewTypeApprove { + if err := StartPRCheckAndAutoMergeBySHA(ctx, review.CommitID, pr.BaseRepo); err != nil { + log.Error("StartPullRequestAutoMergeCheckBySHA: %v", err) + } + } +} + +func (n *automergeNotifier) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + if err := review.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + if err := review.Issue.LoadPullRequest(ctx); err != nil { + log.Error("LoadPullRequest: %v", err) + return + } + // as reviews could have blocked a pending automerge let's recheck + StartPRCheckAndAutoMerge(ctx, review.Issue.PullRequest) +} diff --git a/services/context/base.go b/services/context/base.go index 62fb743714..23f0bcfc33 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -234,9 +234,7 @@ func (b *Base) plainTextInternal(skip, status int, bs []byte) { b.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") b.Resp.Header().Set("X-Content-Type-Options", "nosniff") b.Resp.WriteHeader(status) - if _, err := b.Resp.Write(bs); err != nil { - log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err) - } + _, _ = b.Resp.Write(bs) } // PlainTextBytes renders bytes as plain text @@ -256,7 +254,7 @@ func (b *Base) Redirect(location string, status ...int) { code = status[0] } - if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") || strings.HasPrefix(location, "//") { + if !httplib.IsRelativeURL(location) { // Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path // 1. the first request to "/my-path" contains cookie // 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking) @@ -311,7 +309,8 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) (b *Base, close Locale: middleware.Locale(resp, req), Data: middleware.GetContextData(req.Context()), } - b.AppendContextValue(translation.ContextKey, b.Locale) b.Req = b.Req.WithContext(b) + b.AppendContextValue(translation.ContextKey, b.Locale) + b.AppendContextValue(httplib.RequestContextKey, b.Req) return b, b.cleanUp } diff --git a/services/context/context_response.go b/services/context/context_response.go index d7fd18acac..c43a649b49 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -13,6 +13,7 @@ import ( "path" "strconv" "strings" + "syscall" "time" user_model "code.gitea.io/gitea/models/user" @@ -51,7 +52,7 @@ func (ctx *Context) RedirectToCurrentSite(location ...string) { continue } - if !httplib.IsCurrentGiteaSiteURL(loc) { + if !httplib.IsCurrentGiteaSiteURL(ctx, loc) { continue } @@ -77,7 +78,7 @@ func (ctx *Context) HTML(status int, name base.TplName) { } err := ctx.Render.HTML(ctx.Resp, status, string(name), ctx.Data, ctx.TemplateContext) - if err == nil { + if err == nil || errors.Is(err, syscall.EPIPE) { return } diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index 0c1e5ee54f..3c3fa76e3c 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -39,7 +39,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request { } requestURL, err := url.Parse(path) assert.NoError(t, err) - req := &http.Request{Method: method, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}} + req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}} req = req.WithContext(middleware.WithContextData(req.Context())) return req } @@ -94,6 +94,19 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes return ctx, resp } +func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, *httptest.ResponseRecorder) { + resp := httptest.NewRecorder() + req := mockRequest(t, reqPath) + base, baseCleanUp := context.NewBaseContext(resp, req) + base.Data = middleware.GetContextData(req.Context()) + base.Locale = &translation.MockLocale{} + ctx := &context.PrivateContext{Base: base} + _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later + chiCtx := chi.NewRouteContext() + ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx) + return ctx, resp +} + // LoadRepo load a repo into a test context. func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) { var doer *user_model.User diff --git a/services/convert/issue.go b/services/convert/issue.go index 54b00cd88e..28bef9eb02 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -104,6 +104,8 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss if issue.PullRequest.HasMerged { apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr() } + // Add pr's html url + apiIssue.PullRequest.HTMLURL = issue.HTMLURL() } } if issue.DeadlineUnix != 0 { diff --git a/services/convert/pull.go b/services/convert/pull.go index 775bf3806d..6d95804b38 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -51,29 +51,31 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u } apiPullRequest := &api.PullRequest{ - ID: pr.ID, - URL: pr.Issue.HTMLURL(), - Index: pr.Index, - Poster: apiIssue.Poster, - Title: apiIssue.Title, - Body: apiIssue.Body, - Labels: apiIssue.Labels, - Milestone: apiIssue.Milestone, - Assignee: apiIssue.Assignee, - Assignees: apiIssue.Assignees, - State: apiIssue.State, - IsLocked: apiIssue.IsLocked, - Comments: apiIssue.Comments, - HTMLURL: pr.Issue.HTMLURL(), - DiffURL: pr.Issue.DiffURL(), - PatchURL: pr.Issue.PatchURL(), - HasMerged: pr.HasMerged, - MergeBase: pr.MergeBase, - Mergeable: pr.Mergeable(ctx), - Deadline: apiIssue.Deadline, - Created: pr.Issue.CreatedUnix.AsTimePtr(), - Updated: pr.Issue.UpdatedUnix.AsTimePtr(), - PinOrder: apiIssue.PinOrder, + ID: pr.ID, + URL: pr.Issue.HTMLURL(), + Index: pr.Index, + Poster: apiIssue.Poster, + Title: apiIssue.Title, + Body: apiIssue.Body, + Labels: apiIssue.Labels, + Milestone: apiIssue.Milestone, + Assignee: apiIssue.Assignee, + Assignees: apiIssue.Assignees, + State: apiIssue.State, + Draft: pr.IsWorkInProgress(ctx), + IsLocked: apiIssue.IsLocked, + Comments: apiIssue.Comments, + ReviewComments: pr.GetReviewCommentsCount(ctx), + HTMLURL: pr.Issue.HTMLURL(), + DiffURL: pr.Issue.DiffURL(), + PatchURL: pr.Issue.PatchURL(), + HasMerged: pr.HasMerged, + MergeBase: pr.MergeBase, + Mergeable: pr.Mergeable(ctx), + Deadline: apiIssue.Deadline, + Created: pr.Issue.CreatedUnix.AsTimePtr(), + Updated: pr.Issue.UpdatedUnix.AsTimePtr(), + PinOrder: apiIssue.PinOrder, AllowMaintainerEdit: pr.AllowMaintainerEdit, @@ -168,6 +170,12 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u return nil } + // Outer scope variables to be used in diff calculation + var ( + startCommitID string + endCommitID string + ) + if git.IsErrBranchNotExist(err) { headCommitID, err := headGitRepo.GetRefCommitID(apiPullRequest.Head.Ref) if err != nil && !git.IsErrNotExist(err) { @@ -176,6 +184,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u } if err == nil { apiPullRequest.Head.Sha = headCommitID + endCommitID = headCommitID } } else { commit, err := headBranch.GetCommit() @@ -186,8 +195,17 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u if err == nil { apiPullRequest.Head.Ref = pr.HeadBranch apiPullRequest.Head.Sha = commit.ID.String() + endCommitID = commit.ID.String() } } + + // Calculate diff + startCommitID = pr.MergeBase + + apiPullRequest.ChangedFiles, apiPullRequest.Additions, apiPullRequest.Deletions, err = gitRepo.GetDiffShortStat(startCommitID, endCommitID) + if err != nil { + log.Error("GetDiffShortStat: %v", err) + } } if len(apiPullRequest.Head.Sha) == 0 && len(apiPullRequest.Head.Ref) != 0 { diff --git a/services/convert/user.go b/services/convert/user.go index 2957c58b14..90bcf35cf6 100644 --- a/services/convert/user.go +++ b/services/convert/user.go @@ -53,6 +53,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap FullName: user.FullName, Email: user.GetPlaceholderEmail(), AvatarURL: user.AvatarLink(ctx), + HTMLURL: user.HTMLURL(), Created: user.CreatedUnix.AsTime(), Restricted: user.IsRestricted, Location: user.Location, diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 418a87b863..b4be1e02b7 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -161,6 +161,7 @@ func (f *AuthorizationForm) Validate(req *http.Request, errs binding.Errors) bin // GrantApplicationForm form for authorizing oauth2 clients type GrantApplicationForm struct { ClientID string `binding:"Required"` + Granted bool RedirectURI string State string Scope string diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index d115686491..063c995d52 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -23,7 +23,6 @@ import ( pull_model "code.gitea.io/gitea/models/pull" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/analyze" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" @@ -746,7 +745,7 @@ parsingLoop: diffLineTypeBuffers[DiffLineAdd] = new(bytes.Buffer) diffLineTypeBuffers[DiffLineDel] = new(bytes.Buffer) for _, f := range diff.Files { - f.NameHash = base.EncodeSha1(f.Name) + f.NameHash = git.HashFilePathForWebUI(f.Name) for _, buffer := range diffLineTypeBuffers { buffer.Reset() @@ -1143,7 +1142,7 @@ func GetDiff(ctx context.Context, gitRepo *git.Repository, opts *DiffOptions, fi // so if we are using at least this version of git we don't have to tell ParsePatch to do // the skipping for us parsePatchSkipToFile := opts.SkipTo - if opts.SkipTo != "" && git.CheckGitVersionAtLeast("2.31") == nil { + if opts.SkipTo != "" && git.DefaultFeatures().CheckVersionAtLeast("2.31") { cmdDiff.AddOptionFormat("--skip-to=%s", opts.SkipTo) parsePatchSkipToFile = "" } diff --git a/services/indexer/notify.go b/services/indexer/notify.go index f1e21a2d40..e2cfe477d3 100644 --- a/services/indexer/notify.go +++ b/services/indexer/notify.go @@ -152,3 +152,19 @@ func (r *indexerNotifier) IssueChangeLabels(ctx context.Context, doer *user_mode func (r *indexerNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) { issue_indexer.UpdateIssueIndexer(ctx, issue.ID) } + +func (r *indexerNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID) +} + +func (r *indexerNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + issue_indexer.UpdateIssueIndexer(ctx, pr.Issue.ID) +} diff --git a/services/issue/issue.go b/services/issue/issue.go index b0e50f2b89..72ea66c8d9 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -42,7 +42,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo } } if projectID > 0 { - if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil { return err } } diff --git a/services/mailer/mail.go b/services/mailer/mail.go index a63ba7a52a..04194dcf26 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -289,8 +289,8 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient } // Make sure to compose independent messages to avoid leaking user emails - msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType) - reference := createReference(ctx.Issue, nil, activities_model.ActionType(0)) + msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType) + reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0)) var replyPayload []byte if ctx.Comment != nil { @@ -362,7 +362,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient return msgs, nil } -func createReference(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string { +func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string { var path string if issue.IsPull { path = "pulls" @@ -389,6 +389,10 @@ func createReference(issue *issues_model.Issue, comment *issues_model.Comment, a return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) } +func generateMessageIDForRelease(release *repo_model.Release) string { + return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain) +} + func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string { repo := ctx.Issue.Repo diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 6682774a04..2aac21e552 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -86,11 +86,11 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo msgs := make([]*Message, 0, len(tos)) publisherName := rel.Publisher.DisplayName() - relURL := "<" + rel.HTMLURL() + ">" + msgID := generateMessageIDForRelease(rel) for _, to := range tos { msg := NewMessageFrom(to, publisherName, setting.MailService.FromEmail, subject, mailBody.String()) msg.Info = subject - msg.SetHeader("Message-ID", relURL) + msg.SetHeader("Message-ID", msgID) msgs = append(msgs, msg) } diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index d87c57ffe7..0739f4233f 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -288,7 +288,7 @@ func TestGenerateAdditionalHeaders(t *testing.T) { } } -func Test_createReference(t *testing.T) { +func TestGenerateMessageIDForIssue(t *testing.T) { _, _, issue, comment := prepareMailerTest(t) _, _, pullIssue, _ := prepareMailerTest(t) pullIssue.IsPull = true @@ -388,10 +388,18 @@ func Test_createReference(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := createReference(tt.args.issue, tt.args.comment, tt.args.actionType) + got := generateMessageIDForIssue(tt.args.issue, tt.args.comment, tt.args.actionType) if !strings.HasPrefix(got, tt.prefix) { - t.Errorf("createReference() = %v, want %v", got, tt.prefix) + t.Errorf("generateMessageIDForIssue() = %v, want %v", got, tt.prefix) } }) } } + +func TestGenerateMessageIDForRelease(t *testing.T) { + msgID := generateMessageIDForRelease(&repo_model.Release{ + ID: 1, + Repo: &repo_model.Repository{OwnerName: "owner", Name: "repo"}, + }) + assert.Equal(t, "", msgID) +} diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index 87691bf729..bfe2afa8cc 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -107,7 +107,7 @@ func (g *GiteaLocalUploader) CreateRepo(repo *base.Repository, opts base.Migrate Description: repo.Description, OriginalURL: repo.OriginalURL, GitServiceType: opts.GitServiceType, - IsPrivate: opts.Private, + IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, }) diff --git a/services/pull/check.go b/services/pull/check.go index 9495e8ad5f..7d93ff7a8a 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -39,7 +39,7 @@ var ( ErrHasMerged = errors.New("has already been merged") ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged") ErrIsChecking = errors.New("cannot merge while conflict checking is in progress") - ErrNotMergableState = errors.New("not in mergeable state") + ErrNotMergeableState = errors.New("not in mergeable state") ErrDependenciesLeft = errors.New("is blocked by an open dependency") ) @@ -66,8 +66,8 @@ const ( MergeCheckTypeAuto // Auto Merge (Scheduled Merge) After Checks Succeed ) -// CheckPullMergable check if the pull mergeable based on all conditions (branch protection, merge options, ...) -func CheckPullMergable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, adminSkipProtectionCheck bool) error { +// CheckPullMergeable check if the pull mergeable based on all conditions (branch protection, merge options, ...) +func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, adminSkipProtectionCheck bool) error { return db.WithTx(stdCtx, func(ctx context.Context) error { if pr.HasMerged { return ErrHasMerged @@ -97,7 +97,7 @@ func CheckPullMergable(stdCtx context.Context, doer *user_model.User, perm *acce } if !pr.CanAutoMerge() && !pr.IsEmpty() { - return ErrNotMergableState + return ErrNotMergeableState } if pr.IsChecking() { diff --git a/services/pull/merge.go b/services/pull/merge.go index 00f23e1e3a..20be7c5b5a 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -18,7 +18,6 @@ import ( git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" access_model "code.gitea.io/gitea/models/perm/access" - pull_model "code.gitea.io/gitea/models/pull" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -162,12 +161,6 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) - // Removing an auto merge pull and ignore if not exist - // FIXME: is this the correct point to do this? Shouldn't this be after IsMergeStyleAllowed? - if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) { - return err - } - prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests) if err != nil { log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err) @@ -184,17 +177,15 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U go AddTestPullRequestTask(doer, pr.BaseRepo.ID, pr.BaseBranch, false, "", "") }() - pr.MergedCommitID, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message) + _, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase) if err != nil { return err } - pr.MergedUnix = timeutil.TimeStampNow() - pr.Merger = doer - pr.MergerID = doer.ID - - if _, err := pr.SetMerged(ctx); err != nil { - log.Error("SetMerged %-v: %v", pr, err) + // reload pull request because it has been updated by post receive hook + pr, err = issues_model.GetPullRequestByID(ctx, pr.ID) + if err != nil { + return err } if err := pr.LoadIssue(ctx); err != nil { @@ -245,7 +236,7 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U } // doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository -func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string) (string, error) { +func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, pushTrigger repo_module.PushTrigger) (string, error) { // Clone base repo. mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID) if err != nil { @@ -318,11 +309,13 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use pr.BaseRepo.Name, pr.ID, ) + + mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger)) pushCmd := git.NewCommand(ctx, "push", "origin").AddDynamicArguments(baseBranch + ":" + git.BranchPrefix + pr.BaseBranch) // Push back to upstream. - // TODO: this cause an api call to "/api/internal/hook/post-receive/...", - // that prevents us from doint the whole merge in one db transaction + // This cause an api call to "/api/internal/hook/post-receive/...", + // If it's merge, all db transaction and operations should be there but not here to prevent deadlock. if err := pushCmd.Run(mergeCtx.RunOpts()); err != nil { if strings.Contains(mergeCtx.errbuf.String(), "non-fast-forward") { return "", &git.ErrPushOutOfDate{ diff --git a/services/pull/patch.go b/services/pull/patch.go index 12b79a0625..981bc989fc 100644 --- a/services/pull/patch.go +++ b/services/pull/patch.go @@ -383,7 +383,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo * cmdApply.AddArguments("--ignore-whitespace") } is3way := false - if git.CheckGitVersionAtLeast("2.32.0") == nil { + if git.DefaultFeatures().CheckVersionAtLeast("2.32.0") { cmdApply.AddArguments("--3way") is3way = true } diff --git a/services/pull/temp_repo.go b/services/pull/temp_repo.go index 36bdbde55c..e5753178b8 100644 --- a/services/pull/temp_repo.go +++ b/services/pull/temp_repo.go @@ -104,7 +104,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest) baseBranch := "base" fetchArgs := git.TrustedCmdArgs{"--no-tags"} - if git.CheckGitVersionAtLeast("2.25.0") == nil { + if git.DefaultFeatures().CheckVersionAtLeast("2.25.0") { // Writing the commit graph can be slow and is not needed here fetchArgs = append(fetchArgs, "--no-write-commit-graph") } diff --git a/services/pull/update.go b/services/pull/update.go index bc8c4a25e5..939449b754 100644 --- a/services/pull/update.go +++ b/services/pull/update.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repository" ) // Update updates pull request with base branch. @@ -72,7 +73,7 @@ func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model. BaseBranch: pr.HeadBranch, } - _, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message) + _, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase) defer func() { go AddTestPullRequestTask(doer, reversePR.HeadRepo.ID, reversePR.HeadBranch, false, "", "") diff --git a/services/release/release.go b/services/release/release.go index ba5fd1dd98..399fdc79c0 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -204,7 +204,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo if rel.ID == 0 { return errors.New("UpdateRelease only accepts an exist release") } - isCreated, err := createTag(gitRepo.Ctx, gitRepo, rel, "") + isTagCreated, err := createTag(gitRepo.Ctx, gitRepo, rel, "") if err != nil { return err } @@ -216,6 +216,12 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo } defer committer.Close() + oldRelease, err := repo_model.GetReleaseByID(ctx, rel.ID) + if err != nil { + return err + } + isConvertedFromTag := oldRelease.IsTag && !rel.IsTag + if err = repo_model.UpdateRelease(ctx, rel); err != nil { return err } @@ -292,7 +298,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo } if !rel.IsDraft { - if !isCreated { + if !isTagCreated && !isConvertedFromTag { notify_service.UpdateRelease(gitRepo.Ctx, doer, rel) return nil } diff --git a/services/repository/adopt.go b/services/repository/adopt.go index 31e3e581b3..3d6fe71a09 100644 --- a/services/repository/adopt.go +++ b/services/repository/adopt.go @@ -36,10 +36,6 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR } } - if len(opts.DefaultBranch) == 0 { - opts.DefaultBranch = setting.Repository.DefaultBranch - } - repo := &repo_model.Repository{ OwnerID: u.ID, Owner: u, @@ -80,8 +76,8 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR return fmt.Errorf("getRepositoryByID: %w", err) } - if err := adoptRepository(ctx, repoPath, doer, repo, opts.DefaultBranch); err != nil { - return fmt.Errorf("createDelegateHooks: %w", err) + if err := adoptRepository(ctx, repoPath, repo, opts.DefaultBranch); err != nil { + return fmt.Errorf("adoptRepository: %w", err) } if err := repo_module.CheckDaemonExportOK(ctx, repo); err != nil { @@ -111,7 +107,7 @@ func AdoptRepository(ctx context.Context, doer, u *user_model.User, opts CreateR return repo, nil } -func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, repo *repo_model.Repository, defaultBranch string) (err error) { +func adoptRepository(ctx context.Context, repoPath string, repo *repo_model.Repository, defaultBranch string) (err error) { isExist, err := util.IsExist(repoPath) if err != nil { log.Error("Unable to check if %s exists. Error: %v", repoPath, err) @@ -143,6 +139,21 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r } } + // Don't bother looking this repo in the context it won't be there + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return fmt.Errorf("openRepository: %w", err) + } + defer gitRepo.Close() + + if _, err = repo_module.SyncRepoBranchesWithRepo(ctx, repo, gitRepo, 0); err != nil { + return fmt.Errorf("SyncRepoBranchesWithRepo: %w", err) + } + + if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { + return fmt.Errorf("SyncReleasesWithTags: %w", err) + } + branches, _ := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{ RepoID: repo.ID, ListOptions: db.ListOptionsAll, @@ -183,22 +194,10 @@ func adoptRepository(ctx context.Context, repoPath string, u *user_model.User, r return fmt.Errorf("setDefaultBranch: %w", err) } } - if err = repo_module.UpdateRepository(ctx, repo, false); err != nil { return fmt.Errorf("updateRepository: %w", err) } - // Don't bother looking this repo in the context it won't be there - gitRepo, err := gitrepo.OpenRepository(ctx, repo) - if err != nil { - return fmt.Errorf("openRepository: %w", err) - } - defer gitRepo.Close() - - if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { - return fmt.Errorf("SyncReleasesWithTags: %w", err) - } - return nil } diff --git a/services/repository/commitstatus/commitstatus.go b/services/repository/commitstatus/commitstatus.go index 444ae04d0c..adc59abed8 100644 --- a/services/repository/commitstatus/commitstatus.go +++ b/services/repository/commitstatus/commitstatus.go @@ -115,7 +115,7 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, creato } if status.State.IsSuccess() { - if err := automerge.MergeScheduledPullRequest(ctx, sha, repo); err != nil { + if err := automerge.StartPRCheckAndAutoMergeBySHA(ctx, sha, repo); err != nil { return fmt.Errorf("MergeScheduledPullRequest[repo_id: %d, user_id: %d, sha: %s]: %w", repo.ID, creator.ID, sha, err) } } diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go index e5f7e2af96..ab0e7ffd36 100644 --- a/services/repository/files/patch.go +++ b/services/repository/files/patch.go @@ -148,7 +148,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user stderr := &strings.Builder{} cmdApply := git.NewCommand(ctx, "apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary") - if git.CheckGitVersionAtLeast("2.32") == nil { + if git.DefaultFeatures().CheckVersionAtLeast("2.32") { cmdApply.AddArguments("-3") } diff --git a/services/repository/repository.go b/services/repository/repository.go index d28200c0ad..b7aac3cfe0 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -85,7 +85,7 @@ func PushCreateRepo(ctx context.Context, authUser, owner *user_model.User, repoN repo, err := CreateRepository(ctx, authUser, owner, CreateRepoOptions{ Name: repoName, - IsPrivate: setting.Repository.DefaultPushCreatePrivate, + IsPrivate: setting.Repository.DefaultPushCreatePrivate || setting.Repository.ForcePrivate, }) if err != nil { return nil, err diff --git a/services/task/task.go b/services/task/task.go index e15cab7b3c..c90ee91270 100644 --- a/services/task/task.go +++ b/services/task/task.go @@ -107,7 +107,7 @@ func CreateMigrateTask(ctx context.Context, doer, u *user_model.User, opts base. Description: opts.Description, OriginalURL: opts.OriginalURL, GitServiceType: opts.GitServiceType, - IsPrivate: opts.Private, + IsPrivate: opts.Private || setting.Repository.ForcePrivate, IsMirror: opts.Mirror, Status: repo_model.RepositoryBeingMigrated, }) diff --git a/services/user/avatar.go b/services/user/avatar.go index 2d6c3faf9a..3f87466eaa 100644 --- a/services/user/avatar.go +++ b/services/user/avatar.go @@ -5,8 +5,10 @@ package user import ( "context" + "errors" "fmt" "io" + "os" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" @@ -48,16 +50,24 @@ func UploadAvatar(ctx context.Context, u *user_model.User, data []byte) error { func DeleteAvatar(ctx context.Context, u *user_model.User) error { aPath := u.CustomAvatarRelativePath() log.Trace("DeleteAvatar[%d]: %s", u.ID, aPath) - if len(u.Avatar) > 0 { - if err := storage.Avatars.Delete(aPath); err != nil { - return fmt.Errorf("Failed to remove %s: %w", aPath, err) - } - } - u.UseCustomAvatar = false - u.Avatar = "" - if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { - return fmt.Errorf("DeleteAvatar: %w", err) - } - return nil + return db.WithTx(ctx, func(ctx context.Context) error { + hasAvatar := len(u.Avatar) > 0 + u.UseCustomAvatar = false + u.Avatar = "" + if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil { + return fmt.Errorf("DeleteAvatar: %w", err) + } + + if hasAvatar { + if err := storage.Avatars.Delete(aPath); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to remove %s: %w", aPath, err) + } + log.Warn("Deleting avatar %s but it doesn't exist", aPath) + } + } + + return nil + }) } diff --git a/tailwind.config.js b/tailwind.config.js index d49e9d7a1c..94dfdbced4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -66,7 +66,7 @@ export default { 'xl': '12px', '2xl': '16px', '3xl': '24px', - 'full': 'var(--border-radius-circle)', // 50% + 'full': 'var(--border-radius-full)', }, fontFamily: { sans: 'var(--fonts-regular)', diff --git a/templates/admin/self_check.tmpl b/templates/admin/self_check.tmpl index a6c2ac1ac9..b249bf228e 100644 --- a/templates/admin/self_check.tmpl +++ b/templates/admin/self_check.tmpl @@ -1,4 +1,4 @@ -{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}

@@ -6,7 +6,7 @@

{{if .StartupProblems}} -
+
{{ctx.Locale.Tr "admin.self_check.startup_warnings"}}
    {{range .StartupProblems}}
  • {{.}}
  • {{end}}
@@ -14,8 +14,10 @@
{{end}} +
+ {{if .DatabaseCheckHasProblems}} -
+
{{if .DatabaseType.IsMySQL}}
{{ctx.Locale.Tr "admin.self_check.database_fix_mysql"}}
{{else if .DatabaseType.IsMSSQL}} @@ -29,22 +31,22 @@ {{end}} {{if .DatabaseCheckInconsistentCollationColumns}}
- {{ctx.Locale.Tr "admin.self_check.database_inconsistent_collation_columns" .DatabaseCheckResult.DatabaseCollation}} -
    - {{range .DatabaseCheckInconsistentCollationColumns}} -
  • {{.}}
  • - {{end}} -
+
+ {{ctx.Locale.Tr "admin.self_check.database_inconsistent_collation_columns" .DatabaseCheckResult.DatabaseCollation}} +
    + {{range .DatabaseCheckInconsistentCollationColumns}} +
  • {{.}}
  • + {{end}} +
+
{{end}}
{{end}} - - {{if and (not .StartupProblems) (not .DatabaseCheckHasProblems)}} -
+ {{/* only shown when there is no visible "self-check-problem" */}} +
{{ctx.Locale.Tr "admin.self_check.no_problem_found"}}
- {{end}}
{{template "admin/layout_footer" .}} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index addff22c49..7a3e663c49 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -12,6 +12,14 @@ {{else if .IsSigned}} - {{if EnableTimetracking}} - + {{if and EnableTimetracking .ActiveStopwatch}} +
{{svg "octicon-stopwatch"}}
- {{ctx.Locale.Tr "active_stopwatch"}}
- {{end}} @@ -202,4 +182,33 @@ {{end}}
+ + {{if and .IsSigned EnableTimetracking .ActiveStopwatch}} +
+
+ + {{svg "octicon-issue-opened" 16}} + {{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}} + +
+
+ {{.CsrfTokenHtml}} + +
+
+ {{.CsrfTokenHtml}} + +
+
+
+
+ {{end}} diff --git a/templates/devtest/fomantic-dropdown.tmpl b/templates/devtest/fomantic-dropdown.tmpl new file mode 100644 index 0000000000..57a7c1313e --- /dev/null +++ b/templates/devtest/fomantic-dropdown.tmpl @@ -0,0 +1,109 @@ +{{template "base/head" .}} + +
+
+

Dropdown

+
+ + + + +
+ + +
+
+ +

Selection

+
+ {{/* the "selection" class is optional, it will be added by JS automatically */}} + + +
+

Dropdown Button (demo only without menu)

+
+ + + +
+ +
+ + + +
+ +
+
+
Other button align with ...
+ +
+
+
+{{template "base/footer" .}} diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index 3b13c13be8..ea293fd3b4 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -180,94 +180,6 @@
- -

Dropdown with SVG

-
- - - - -
- - -
-
- -
- - - -
- -
- - - -
- -
-
-
Button align with ...
- -
diff --git a/templates/install.tmpl b/templates/install.tmpl index f3117af547..965e57f213 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -157,168 +157,171 @@

{{ctx.Locale.Tr "install.optional_title"}}

- - -
- - {{ctx.Locale.Tr "install.email_title"}} - -
- - -
-
- - -
-
- - - {{ctx.Locale.TrString "install.smtp_from_helper"}}{{/* it contains lt/gt chars*/}} -
-
- - -
-
- - -
-
-
- - +
+ +
+ + {{ctx.Locale.Tr "install.email_title"}} + +
+ +
-
-
-
- - +
+ +
-
-
- - -
- - {{ctx.Locale.Tr "install.server_service_title"}} - -
-
- - +
+ + + {{ctx.Locale.TrString "install.smtp_from_helper"}}{{/* it contains lt/gt chars*/}}
-
-
-
- - +
+ +
-
-
-
- - +
+ +
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- - - {{ctx.Locale.Tr "install.no_reply_address_helper"}} -
-
- -
+
+
+ + +
+
+ - -
- - {{ctx.Locale.Tr "install.admin_title"}} - -

{{ctx.Locale.Tr "install.admin_setting_desc"}}

-
- - -
-
- - -
-
- - -
-
- - -
-
+ +
+ + {{ctx.Locale.Tr "install.server_service_title"}} + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "install.no_reply_address_helper"}} +
+
+ + + {{ctx.Locale.Tr "install.password_algorithm_helper"}} +
+
+ + +
+ + {{ctx.Locale.Tr "install.admin_title"}} + +

{{ctx.Locale.Tr "install.admin_setting_desc"}}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
{{if .EnvConfigKeys}} @@ -333,12 +336,11 @@ {{end}} -
These configuration options will be written into: {{.CustomConfFile}}
-
+
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index c519606d1f..698a9559c5 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -40,8 +40,9 @@ {{end}} {{if .IsOrganizationOwner}} + - {{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}} + {{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}} {{end}}
diff --git a/templates/org/team/members.tmpl b/templates/org/team/members.tmpl index 7e9a59a6bf..5433f01530 100644 --- a/templates/org/team/members.tmpl +++ b/templates/org/team/members.tmpl @@ -8,7 +8,7 @@
{{template "org/team/navbar" .}} {{if .IsOrganizationOwner}} -
+
{{.CsrfTokenHtml}} @@ -21,7 +21,7 @@
{{end}} -
+
{{range .Team.Members}}
diff --git a/templates/org/team/navbar.tmpl b/templates/org/team/navbar.tmpl index 8f2571e1f6..9704f63f6f 100644 --- a/templates/org/team/navbar.tmpl +++ b/templates/org/team/navbar.tmpl @@ -1,4 +1,4 @@ -
{{end}} -
+
{{range .Team.Repos}}
diff --git a/templates/package/content/maven.tmpl b/templates/package/content/maven.tmpl index 3a7de335de..f56595a830 100644 --- a/templates/package/content/maven.tmpl +++ b/templates/package/content/maven.tmpl @@ -1,4 +1,8 @@ -{{if eq .PackageDescriptor.Package.Type "maven"}} +{{if and (eq .PackageDescriptor.Package.Type "maven") (not .PackageDescriptor.Metadata)}} +

{{ctx.Locale.Tr "packages.installation"}}

+
{{ctx.Locale.Tr "packages.no_metadata"}}
+{{end}} +{{if and (eq .PackageDescriptor.Package.Type "maven") .PackageDescriptor.Metadata}}

{{ctx.Locale.Tr "packages.installation"}}

diff --git a/templates/package/metadata/maven.tmpl b/templates/package/metadata/maven.tmpl index 548be61790..36412723d2 100644 --- a/templates/package/metadata/maven.tmpl +++ b/templates/package/metadata/maven.tmpl @@ -1,4 +1,7 @@ -{{if eq .PackageDescriptor.Package.Type "maven"}} +{{if and (eq .PackageDescriptor.Package.Type "maven") (not .PackageDescriptor.Metadata)}} +
{{svg "octicon-note" 16 "tw-mr-2"}} {{ctx.Locale.Tr "packages.no_metadata"}}
+{{end}} +{{if and (eq .PackageDescriptor.Package.Type "maven") .PackageDescriptor.Metadata}} {{if .PackageDescriptor.Metadata.Name}}
{{svg "octicon-note" 16 "tw-mr-2"}} {{.PackageDescriptor.Metadata.Name}}
{{end}} {{if .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external" 16 "tw-mr-2"}} {{ctx.Locale.Tr "packages.details.project_site"}}
{{end}} {{range .PackageDescriptor.Metadata.Licenses}}
{{svg "octicon-law" 16 "tw-mr-2"}} {{.}}
{{end}} diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 3e000660e2..45c8461218 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -64,22 +64,18 @@
-
+
{{range .Columns}}
-
-
- {{.NumIssues ctx}} -
- {{.Title}} +
+ {{.NumIssues ctx}}
+
{{.Title}}
{{if $canWriteProject}} -