From c9beb0b01f56b0ddd3668f20801a34ae18e6b09a Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sat, 25 Oct 2025 11:37:33 -0600 Subject: [PATCH 001/103] Support actions and reusable workflows from private repos (#32562) Resolve https://gitea.com/gitea/act_runner/issues/102 This PR allows administrators of a private repository to specify some collaborative owners. The repositories of collaborative owners will be allowed to access this repository's actions and workflows. Settings for private repos: ![image](https://github.com/user-attachments/assets/e591c877-f94d-48fb-82f3-3b051f21557e) --- This PR also moves "Enable Actions" setting to `Actions > General` page image image --------- Signed-off-by: Zettat123 Co-authored-by: ChristopherHX --- models/fixtures/action_run.yml | 20 +++ models/fixtures/action_run_job.yml | 14 +++ models/fixtures/action_task.yml | 20 +++ models/fixtures/repo_unit.yml | 7 ++ models/perm/access/repo_permission.go | 19 ++- models/repo/repo_unit.go | 17 +++ models/user/search.go | 11 +- models/user/user.go | 12 ++ models/user/user_test.go | 4 +- options/locale/locale_en-US.ini | 9 ++ routers/api/v1/admin/org.go | 2 +- routers/api/v1/admin/user.go | 2 +- routers/api/v1/org/org.go | 2 +- routers/api/v1/user/user.go | 2 +- routers/web/admin/orgs.go | 2 +- routers/web/admin/users.go | 2 +- routers/web/explore/org.go | 2 +- routers/web/explore/user.go | 2 +- routers/web/home.go | 2 +- routers/web/repo/githttp.go | 2 +- routers/web/repo/setting/actions.go | 121 +++++++++++++++++++ routers/web/repo/setting/setting.go | 6 - routers/web/user/search.go | 6 +- routers/web/web.go | 10 ++ templates/repo/settings/actions.tmpl | 2 + templates/repo/settings/actions_general.tmpl | 69 +++++++++++ templates/repo/settings/navbar.tmpl | 9 +- templates/repo/settings/options.tmpl | 12 -- tests/integration/actions_settings_test.go | 62 ++++++++++ web_src/js/features/comp/SearchUserBox.ts | 3 +- 30 files changed, 408 insertions(+), 45 deletions(-) create mode 100644 routers/web/repo/setting/actions.go create mode 100644 templates/repo/settings/actions_general.tmpl create mode 100644 tests/integration/actions_settings_test.go diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index 09dfa6cccb..b9688dd5f5 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -139,3 +139,23 @@ updated: 1683636626 need_approval: 0 approved_by: 0 +- + id: 804 + title: "use a private action" + repo_id: 60 + owner_id: 40 + workflow_id: "run.yaml" + index: 189 + trigger_user_id: 40 + ref: "refs/heads/master" + commit_sha: "6e64b26de7ba966d01d90ecfaf5c7f14ef203e86" + event: "push" + trigger_event: "push" + is_fork_pull_request: 0 + status: 1 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index 6c06d94aa4..337e83605a 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -129,3 +129,17 @@ status: 5 started: 1683636528 stopped: 1683636626 +- + id: 205 + run_id: 804 + repo_id: 6 + owner_id: 10 + commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 + is_fork_pull_request: 0 + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 48 + status: 1 + started: 1683636528 + stopped: 1683636626 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index c79fb07050..e09fd6f2ec 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -177,3 +177,23 @@ log_length: 0 log_size: 0 log_expired: 0 +- + id: 55 + job_id: 205 + attempt: 1 + runner_id: 1 + status: 6 # 6 is the status code for "running" + started: 1683636528 + stopped: 1683636626 + repo_id: 6 + owner_id: 10 + commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86 + is_fork_pull_request: 0 + token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc478422b + token_salt: ERxJGHvg3I + token_last_eight: 182199eb + log_filename: collaborative-owner-test/1a/49.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index f6b6252da1..f8bb8ef0d3 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -733,3 +733,10 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 + +- + id: 111 + repo_id: 3 + type: 10 + config: "{}" + created_unix: 946684810 diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index df96db8d5a..ba7544f343 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -264,13 +264,22 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito if err != nil { return perm, err } - if task.RepoID != repo.ID { - // FIXME allow public repo read access if tokenless pull is enabled - return perm, nil - } var accessMode perm_model.AccessMode - if task.IsForkPullRequest { + if task.RepoID != repo.ID { + taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) + if err != nil || !exist { + return perm, err + } + actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { + // The task repo can access the current repo only if the task repo is private and + // the owner of the task repo is a collaborative owner of the current repo. + // FIXME allow public repo read access if tokenless pull is enabled + return perm, nil + } + accessMode = perm_model.AccessModeRead + } else if task.IsForkPullRequest { accessMode = perm_model.AccessModeRead } else { accessMode = perm_model.AccessModeWrite diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index a5207bc22a..ad0bb9d3f8 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -170,6 +170,9 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { type ActionsConfig struct { DisabledWorkflows []string + // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. + // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. + CollaborativeOwnerIDs []int64 } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -192,6 +195,20 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) { cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) } +func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { + if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { + cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) + } +} + +func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { + cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) +} + +func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { + return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) diff --git a/models/user/search.go b/models/user/search.go index cfd0d011bc..db4b07f64a 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -6,6 +6,7 @@ package user import ( "context" "fmt" + "slices" "strings" "code.gitea.io/gitea/models/db" @@ -22,7 +23,7 @@ type SearchUserOptions struct { db.ListOptions Keyword string - Type UserType + Types []UserType UID int64 LoginName string // this option should be used only for admin user SourceID int64 // this option should be used only for admin user @@ -43,16 +44,16 @@ type SearchUserOptions struct { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { var cond builder.Cond - cond = builder.Eq{"type": opts.Type} + cond = builder.In("type", opts.Types) if opts.IncludeReserved { - switch opts.Type { - case UserTypeIndividual: + switch { + case slices.Contains(opts.Types, UserTypeIndividual): cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( builder.Eq{"type": UserTypeBot}, ).Or( builder.Eq{"type": UserTypeRemoteUser}, ) - case UserTypeOrganization: + case slices.Contains(opts.Types, UserTypeOrganization): cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) } } diff --git a/models/user/user.go b/models/user/user.go index 3583694cf9..d6e1eec276 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1449,3 +1449,15 @@ func DisabledFeaturesWithLoginType(user *User) *container.Set[string] { } return &setting.Admin.UserDisabledFeatures } + +// GetUserOrOrgIDByName returns the id for a user or an org by name +func GetUserOrOrgIDByName(ctx context.Context, name string) (int64, error) { + var id int64 + has, err := db.GetEngine(ctx).Table("user").Where("name = ?", name).Cols("id").Get(&id) + if err != nil { + return 0, err + } else if !has { + return 0, fmt.Errorf("user or org with name %s: %w", name, util.ErrNotExist) + } + return id, nil +} diff --git a/models/user/user_test.go b/models/user/user_test.go index 6a530553d7..923f2cd40e 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -126,7 +126,7 @@ func TestSearchUsers(t *testing.T) { // test orgs testOrgSuccess := func(opts user_model.SearchUserOptions, expectedOrgIDs []int64) { - opts.Type = user_model.UserTypeOrganization + opts.Types = []user_model.UserType{user_model.UserTypeOrganization} testSuccess(opts, expectedOrgIDs) } @@ -150,7 +150,7 @@ func TestSearchUsers(t *testing.T) { // test users testUserSuccess := func(opts user_model.SearchUserOptions, expectedUserIDs []int64) { - opts.Type = user_model.UserTypeIndividual + opts.Types = []user_model.UserType{user_model.UserTypeIndividual} testSuccess(opts, expectedUserIDs) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 46fdf06022..ddc12aefaa 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3914,6 +3914,15 @@ variables.update.success = The variable has been edited. logs.always_auto_scroll = Always auto scroll logs logs.always_expand_running = Always expand running logs +general = General +general.enable_actions = Enable Actions +general.collaborative_owners_management = Collaborative Owners Management +general.collaborative_owners_management_help = A collaborative owner is a user or an organization whose private repository has access to the actions and workflows of this repository. +general.add_collaborative_owner = Add Collaborative Owner +general.collaborative_owner_not_exist = The collaborative owner does not exist. +general.remove_collaborative_owner = Remove Collaborative Owner +general.remove_collaborative_owner_desc = Removing a collaborative owner will prevent the repositories of the owner from accessing the actions in this repository. Continue? + [projects] deleted.display_name = Deleted Project type-1.display_name = Individual Project diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index c3473372f2..62afcb00d9 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -103,7 +103,7 @@ func GetAllOrgs(ctx *context.APIContext) { users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeOrganization, + Types: []user_model.UserType{user_model.UserTypeOrganization}, OrderBy: db.SearchOrderByAlphabetically, ListOptions: listOptions, Visible: []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate}, diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 494bace585..6afa651448 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -425,7 +425,7 @@ func SearchUsers(ctx *context.APIContext) { users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeIndividual, + Types: []user_model.UserType{user_model.UserTypeIndividual}, LoginName: ctx.FormTrim("login_name"), SourceID: ctx.FormInt64("source_id"), OrderBy: db.SearchOrderByAlphabetically, diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index cd67686065..08e37e8df4 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -202,7 +202,7 @@ func GetAll(ctx *context.APIContext) { publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, ListOptions: listOptions, - Type: user_model.UserTypeOrganization, + Types: []user_model.UserType{user_model.UserTypeOrganization}, OrderBy: db.SearchOrderByAlphabetically, Visible: vMode, }) diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 6de1125c40..f7b9301795 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -77,7 +77,7 @@ func Search(ctx *context.APIContext) { Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), UID: uid, - Type: user_model.UserTypeIndividual, + Types: []user_model.UserType{user_model.UserTypeIndividual}, SearchByEmail: true, Visible: visible, ListOptions: listOptions, diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index e34f203aaf..62a8b30b13 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -29,7 +29,7 @@ func Organizations(ctx *context.Context) { explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeOrganization, + Types: []user_model.UserType{user_model.UserTypeOrganization}, IncludeReserved: true, // administrator needs to list all accounts include reserved ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.OrgPagingNum, diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 27577cd35b..1f22d800a9 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -67,7 +67,7 @@ func Users(ctx *context.Context) { explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeIndividual, + Types: []user_model.UserType{user_model.UserTypeIndividual}, ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.UserPagingNum, }, diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index f8f7f5c18c..4d25f4ec2d 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -46,7 +46,7 @@ func Organizations(ctx *context.Context) { RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeOrganization, + Types: []user_model.UserType{user_model.UserTypeOrganization}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, Visible: visibleTypes, diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index 40d3e2a060..4b3c269410 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -153,7 +153,7 @@ func Users(ctx *context.Context) { RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Type: user_model.UserTypeIndividual, + Types: []user_model.UserType{user_model.UserTypeIndividual}, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate}, diff --git a/routers/web/home.go b/routers/web/home.go index 4b15ee83c2..7efa5f344e 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -69,7 +69,7 @@ func HomeSitemap(ctx *context.Context) { m := sitemap.NewSitemapIndex() if !setting.Service.Explore.DisableUsersPage { _, cnt, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ - Type: user_model.UserTypeIndividual, + Types: []user_model.UserType{user_model.UserTypeIndividual}, ListOptions: db.ListOptions{PageSize: 1}, IsActive: optional.Some(true), Visible: []structs.VisibleType{structs.VisibleTypePublic}, diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 1b1c272a8d..c6f5f74e4b 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -191,7 +191,7 @@ func httpBase(ctx *context.Context) *serviceHandler { taskID := ctx.Data["ActionsTaskID"].(int64) p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID) if err != nil { - ctx.ServerError("GetUserRepoPermission", err) + ctx.ServerError("GetActionsUserRepoPermission", err) return nil } diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go new file mode 100644 index 0000000000..9c2c9242d3 --- /dev/null +++ b/routers/web/repo/setting/actions.go @@ -0,0 +1,121 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const tplRepoActionsGeneralSettings templates.TplName = "repo/settings/actions" + +func ActionsGeneralSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.general") + ctx.Data["PageType"] = "general" + ctx.Data["PageIsActionsSettingsGeneral"] = true + + actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) + if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + if actionsUnit == nil { // no actions unit + ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings) + return + } + + if ctx.Repo.Repository.IsPrivate { + collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs + collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs) + if err != nil { + ctx.ServerError("GetUsersByIDs", err) + return + } + ctx.Data["CollaborativeOwners"] = collaborativeOwners + } + + ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings) +} + +func ActionsUnitPost(ctx *context.Context) { + redirectURL := ctx.Repo.RepoLink + "/settings/actions/general" + enableActionsUnit := ctx.FormBool("enable_actions") + repo := ctx.Repo.Repository + + var err error + if enableActionsUnit && !unit_model.TypeActions.UnitGlobalDisabled() { + err = repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{newRepoUnit(repo, unit_model.TypeActions, nil)}, nil) + } else if !unit_model.TypeActions.UnitGlobalDisabled() { + err = repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeActions}) + } + + if err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(redirectURL) +} + +func AddCollaborativeOwner(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("collaborative_owner")) + + ownerID, err := user_model.GetUserOrOrgIDByName(ctx, name) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.JSONErrorNotFound() + } else { + ctx.ServerError("GetUserOrOrgIDByName", err) + } + return + } + + actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + actionsCfg := actionsUnit.ActionsConfig() + actionsCfg.AddCollaborativeOwner(ownerID) + if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + + ctx.JSONOK() +} + +func DeleteCollaborativeOwner(ctx *context.Context) { + ownerID := ctx.FormInt64("id") + + actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + actionsCfg := actionsUnit.ActionsConfig() + if !actionsCfg.IsCollaborativeOwner(ownerID) { + ctx.Flash.Error(ctx.Tr("actions.general.collaborative_owner_not_exist")) + ctx.JSONErrorNotFound() + return + } + actionsCfg.RemoveCollaborativeOwner(ownerID) + if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index dd887d6edf..0b0c990ae0 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -613,12 +613,6 @@ func handleSettingsPostAdvanced(ctx *context.Context) { deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) } - if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { - units = append(units, newRepoUnit(repo, unit_model.TypeActions, nil)) - } else if !unit_model.TypeActions.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) - } - if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { units = append(units, newRepoUnit(repo, unit_model.TypePullRequests, &repo_model.PullRequestsConfig{ IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, diff --git a/routers/web/user/search.go b/routers/web/user/search.go index 9acb9694d7..b2a15bf90e 100644 --- a/routers/web/user/search.go +++ b/routers/web/user/search.go @@ -16,10 +16,14 @@ import ( // SearchCandidates searches candidate users for dropdown list func SearchCandidates(ctx *context.Context) { + searchUserTypes := []user_model.UserType{user_model.UserTypeIndividual} + if ctx.FormBool("orgs") { + searchUserTypes = append(searchUserTypes, user_model.UserTypeOrganization) + } users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), - Type: user_model.UserTypeIndividual, + Types: searchUserTypes, IsActive: optional.Some(true), ListOptions: db.ListOptions{PageSize: setting.UI.MembersPagingNum}, }) diff --git a/routers/web/web.go b/routers/web/web.go index 9b3cfb6d16..43f104a73e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1159,11 +1159,21 @@ func registerWebRoutes(m *web.Router) { m.Post("/{lid}/unlock", repo_setting.LFSUnlock) }) }) + m.Group("/actions/general", func() { + m.Get("", repo_setting.ActionsGeneralSettings) + m.Post("/actions_unit", repo_setting.ActionsUnitPost) + }) m.Group("/actions", func() { m.Get("", shared_actions.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + m.Group("/general", func() { + m.Group("/collaborative_owner", func() { + m.Post("/add", repo_setting.AddCollaborativeOwner) + m.Post("/delete", repo_setting.DeleteCollaborativeOwner) + }) + }) }, actions.MustEnableActions) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed m.Group("/migrate", func() { diff --git a/templates/repo/settings/actions.tmpl b/templates/repo/settings/actions.tmpl index f38ab5b658..5388de35af 100644 --- a/templates/repo/settings/actions.tmpl +++ b/templates/repo/settings/actions.tmpl @@ -6,6 +6,8 @@ {{template "shared/secrets/add_list" .}} {{else if eq .PageType "variables"}} {{template "shared/variables/variable_list" .}} + {{else if eq .PageType "general"}} + {{template "repo/settings/actions_general" .}} {{end}} {{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/actions_general.tmpl b/templates/repo/settings/actions_general.tmpl new file mode 100644 index 0000000000..96d854ca8d --- /dev/null +++ b/templates/repo/settings/actions_general.tmpl @@ -0,0 +1,69 @@ +
+

+ {{ctx.Locale.Tr "actions.general.enable_actions"}} +

+
+
+ {{.CsrfTokenHtml}} + {{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}} + {{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}} +
+ +
+ + +
+
+ {{if not $isActionsGlobalDisabled}} +
+
+ +
+ {{end}} +
+
+ + {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} + {{if .Repository.IsPrivate}} +

+ {{ctx.Locale.Tr "actions.general.collaborative_owners_management"}} +

+ {{if len .CollaborativeOwners}} +
+
+ {{range .CollaborativeOwners}} +
+ +
+
+ {{template "shared/user/name" .}} +
+
+
+ +
+
+ {{end}} +
+
+ {{end}} +
+
+ {{.CsrfTokenHtml}} + + +
+
+ {{ctx.Locale.Tr "actions.general.collaborative_owners_management_help"}} +
+ {{end}} + {{end}} +
diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 3dd86d1f6a..ba25e34ba4 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -38,10 +38,13 @@ {{end}} {{end}} - {{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}} -
+
{{ctx.Locale.Tr "actions.actions"}}
- {{end}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index fc42056e0a..b4680431b8 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -509,18 +509,6 @@ - {{if .EnableActions}} - {{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}} - {{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}} -
- -
- - -
-
- {{end}} - {{if not .IsMirror}}
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}} diff --git a/tests/integration/actions_settings_test.go b/tests/integration/actions_settings_test.go new file mode 100644 index 0000000000..935d8bbceb --- /dev/null +++ b/tests/integration/actions_settings_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestActionsCollaborativeOwner(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user2 is the owner of "reusable_workflow" repo + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + repo := createActionsTestRepo(t, user2Token, "reusable_workflow", true) + + // a private repo(id=6) of user10 will try to clone "reusable_workflow" repo + user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + // task id is 55 and its repo_id=6 + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 55, RepoID: 6}) + taskToken := "674f727a81ed2f195bccab036cccf86a182199eb" + tokenHash := auth_model.HashToken(taskToken, task.TokenSalt) + assert.Equal(t, task.TokenHash, tokenHash) + + dstPath := t.TempDir() + u.Path = fmt.Sprintf("%s/%s.git", repo.Owner.UserName, repo.Name) + u.User = url.UserPassword("gitea-actions", taskToken) + + // the git clone will fail + doGitCloneFail(u)(t) + + // add user10 to the list of collaborative owners + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", repo.Owner.UserName, repo.Name), map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + "collaborative_owner": user10.Name, + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // the git clone will be successful + doGitClone(dstPath, u)(t) + + // remove user10 from the list of collaborative owners + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", repo.Owner.UserName, repo.Name, user10.ID), map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // the git clone will fail + doGitCloneFail(u)(t) + }) +} diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 4b13a2141f..9cba18b356 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -10,10 +10,11 @@ export function initCompSearchUserBox() { const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true'; const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined; + const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true'; fomanticQuery(searchUserBox).search({ minCharacters: 2, apiSettings: { - url: `${appSubUrl}/user/search_candidates?q={query}`, + url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`, onResponse(response: any) { const resultItems = []; const searchQuery = searchUserBox.querySelector('input').value; From 2a6af15448312789ef604b70f8212a6e8e533620 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sun, 26 Oct 2025 00:38:59 +0000 Subject: [PATCH 002/103] [skip ci] Updated translations via Crowdin --- options/locale/locale_cs-CZ.ini | 1 + options/locale/locale_de-DE.ini | 1 + options/locale/locale_el-GR.ini | 1 + options/locale/locale_es-ES.ini | 1 + options/locale/locale_fa-IR.ini | 1 + options/locale/locale_fi-FI.ini | 1 + options/locale/locale_fr-FR.ini | 1 + options/locale/locale_ga-IE.ini | 1 + options/locale/locale_hu-HU.ini | 1 + options/locale/locale_id-ID.ini | 1 + options/locale/locale_is-IS.ini | 1 + options/locale/locale_it-IT.ini | 1 + options/locale/locale_ja-JP.ini | 1 + options/locale/locale_ko-KR.ini | 1 + options/locale/locale_lv-LV.ini | 1 + options/locale/locale_nl-NL.ini | 1 + options/locale/locale_pl-PL.ini | 1 + options/locale/locale_pt-BR.ini | 1 + options/locale/locale_pt-PT.ini | 1 + options/locale/locale_ru-RU.ini | 1 + options/locale/locale_si-LK.ini | 1 + options/locale/locale_sk-SK.ini | 1 + options/locale/locale_sv-SE.ini | 1 + options/locale/locale_tr-TR.ini | 1 + options/locale/locale_uk-UA.ini | 1 + options/locale/locale_zh-CN.ini | 1 + options/locale/locale_zh-HK.ini | 1 + options/locale/locale_zh-TW.ini | 1 + 28 files changed, 28 insertions(+) diff --git a/options/locale/locale_cs-CZ.ini b/options/locale/locale_cs-CZ.ini index 26c1131a56..384c650054 100644 --- a/options/locale/locale_cs-CZ.ini +++ b/options/locale/locale_cs-CZ.ini @@ -3586,6 +3586,7 @@ variables.update.success=Proměnná byla upravena. logs.always_auto_scroll=Vždy automaticky posouvat logy logs.always_expand_running=Vždy rozšířit běžící logy + [projects] deleted.display_name=Odstraněný projekt type-1.display_name=Samostatný projekt diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index 3613032b2d..a4ff1e8a08 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -3645,6 +3645,7 @@ variables.update.success=Die Variable wurde bearbeitet. logs.always_auto_scroll=Autoscroll für Logs immer aktivieren logs.always_expand_running=Laufende Logs immer erweitern + [projects] deleted.display_name=Gelöschtes Projekt type-1.display_name=Individuelles Projekt diff --git a/options/locale/locale_el-GR.ini b/options/locale/locale_el-GR.ini index 42b23dea05..86397138a3 100644 --- a/options/locale/locale_el-GR.ini +++ b/options/locale/locale_el-GR.ini @@ -3280,6 +3280,7 @@ variables.update.failed=Αποτυχία επεξεργασίας μεταβλη variables.update.success=Η μεταβλητή έχει τροποποιηθεί. + [projects] type-1.display_name=Ατομικό Έργο type-2.display_name=Έργο Αποθετηρίου diff --git a/options/locale/locale_es-ES.ini b/options/locale/locale_es-ES.ini index ffacb46b19..cf87084f3b 100644 --- a/options/locale/locale_es-ES.ini +++ b/options/locale/locale_es-ES.ini @@ -3257,6 +3257,7 @@ variables.update.failed=Error al editar la variable. variables.update.success=La variable ha sido editada. + [projects] type-1.display_name=Proyecto individual type-2.display_name=Proyecto repositorio diff --git a/options/locale/locale_fa-IR.ini b/options/locale/locale_fa-IR.ini index abeef31988..b0a6cc799f 100644 --- a/options/locale/locale_fa-IR.ini +++ b/options/locale/locale_fa-IR.ini @@ -2446,6 +2446,7 @@ runs.commit=کامیت + [projects] [git.filemode] diff --git a/options/locale/locale_fi-FI.ini b/options/locale/locale_fi-FI.ini index 9d7fc033c4..c9c3ca4e2a 100644 --- a/options/locale/locale_fi-FI.ini +++ b/options/locale/locale_fi-FI.ini @@ -1693,6 +1693,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index 7fc8ba3b11..2a00efc479 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -3906,6 +3906,7 @@ variables.update.success=La variable a bien été modifiée. logs.always_auto_scroll=Toujours faire défiler les journaux automatiquement logs.always_expand_running=Toujours développer les journaux en cours + [projects] deleted.display_name=Projet supprimé type-1.display_name=Projet personnel diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index 8173b92acc..8db1ef2f30 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -3914,6 +3914,7 @@ variables.update.success=Tá an t-athróg curtha in eagar. logs.always_auto_scroll=Logchomhaid scrollaithe uathoibríoch i gcónaí logs.always_expand_running=Leathnaigh logs reatha i gcónaí + [projects] deleted.display_name=Tionscadal scriosta type-1.display_name=Tionscadal Aonair diff --git a/options/locale/locale_hu-HU.ini b/options/locale/locale_hu-HU.ini index 0fc86f0775..b2a54fa7b7 100644 --- a/options/locale/locale_hu-HU.ini +++ b/options/locale/locale_hu-HU.ini @@ -1605,6 +1605,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_id-ID.ini b/options/locale/locale_id-ID.ini index a5640655d2..aff4a3bbc3 100644 --- a/options/locale/locale_id-ID.ini +++ b/options/locale/locale_id-ID.ini @@ -1428,6 +1428,7 @@ variables.update.failed=Gagal mengedit variabel. variables.update.success=Variabel telah diedit. + [projects] type-1.display_name=Proyek Individu type-2.display_name=Proyek Repositori diff --git a/options/locale/locale_is-IS.ini b/options/locale/locale_is-IS.ini index 177cf61068..6e3041c593 100644 --- a/options/locale/locale_is-IS.ini +++ b/options/locale/locale_is-IS.ini @@ -1334,6 +1334,7 @@ runs.commit=Framlag + [projects] [git.filemode] diff --git a/options/locale/locale_it-IT.ini b/options/locale/locale_it-IT.ini index 31aebff2d6..fff612a896 100644 --- a/options/locale/locale_it-IT.ini +++ b/options/locale/locale_it-IT.ini @@ -2706,6 +2706,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index 475954f03a..3dfd143d37 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -3910,6 +3910,7 @@ variables.update.success=変数を更新しました。 logs.always_auto_scroll=常にログを自動スクロール logs.always_expand_running=常に実行中のログを展開 + [projects] deleted.display_name=削除されたプロジェクト type-1.display_name=個人プロジェクト diff --git a/options/locale/locale_ko-KR.ini b/options/locale/locale_ko-KR.ini index 39df68f126..405bcdd98b 100644 --- a/options/locale/locale_ko-KR.ini +++ b/options/locale/locale_ko-KR.ini @@ -1554,6 +1554,7 @@ runs.commit=커밋 + [projects] [git.filemode] diff --git a/options/locale/locale_lv-LV.ini b/options/locale/locale_lv-LV.ini index 9c8f47534b..81f8aaaf28 100644 --- a/options/locale/locale_lv-LV.ini +++ b/options/locale/locale_lv-LV.ini @@ -3282,6 +3282,7 @@ variables.update.failed=Neizdevās labot mainīgo. variables.update.success=Mainīgais tika labots. + [projects] type-1.display_name=Individuālais projekts type-2.display_name=Repozitorija projekts diff --git a/options/locale/locale_nl-NL.ini b/options/locale/locale_nl-NL.ini index 326a203c8f..7a0c2b3f5a 100644 --- a/options/locale/locale_nl-NL.ini +++ b/options/locale/locale_nl-NL.ini @@ -2458,6 +2458,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_pl-PL.ini b/options/locale/locale_pl-PL.ini index 872f1104ab..540f5ee629 100644 --- a/options/locale/locale_pl-PL.ini +++ b/options/locale/locale_pl-PL.ini @@ -2347,6 +2347,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index b6292c139b..d5bd3175f8 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -3615,6 +3615,7 @@ variables.update.failed=Falha ao editar a variável. variables.update.success=A variável foi editada. + [projects] deleted.display_name=Excluir Projeto type-1.display_name=Projeto Individual diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 4497a589d4..39168bdaaa 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -3914,6 +3914,7 @@ variables.update.success=A variável foi editada. logs.always_auto_scroll=Rolar registos de forma automática e permanente logs.always_expand_running=Expandir sempre os registos que vão rolando + [projects] deleted.display_name=Planeamento eliminado type-1.display_name=Planeamento individual diff --git a/options/locale/locale_ru-RU.ini b/options/locale/locale_ru-RU.ini index 6da0eea91c..2625b4382b 100644 --- a/options/locale/locale_ru-RU.ini +++ b/options/locale/locale_ru-RU.ini @@ -3225,6 +3225,7 @@ variables.update.failed=Не удалось изменить переменну variables.update.success=Переменная изменена. + [projects] type-1.display_name=Индивидуальный проект type-2.display_name=Проект репозитория diff --git a/options/locale/locale_si-LK.ini b/options/locale/locale_si-LK.ini index e065527bbe..49ca5b042e 100644 --- a/options/locale/locale_si-LK.ini +++ b/options/locale/locale_si-LK.ini @@ -2391,6 +2391,7 @@ runs.commit=කැප + [projects] [git.filemode] diff --git a/options/locale/locale_sk-SK.ini b/options/locale/locale_sk-SK.ini index dc022da24b..54b8326f33 100644 --- a/options/locale/locale_sk-SK.ini +++ b/options/locale/locale_sk-SK.ini @@ -1292,6 +1292,7 @@ runners.labels=Štítky + [projects] [git.filemode] diff --git a/options/locale/locale_sv-SE.ini b/options/locale/locale_sv-SE.ini index e8c13cae0d..79abdce4ab 100644 --- a/options/locale/locale_sv-SE.ini +++ b/options/locale/locale_sv-SE.ini @@ -1968,6 +1968,7 @@ runs.commit=Commit + [projects] [git.filemode] diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index ccc74293f5..8be6a587fb 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -3907,6 +3907,7 @@ variables.update.success=Değişken düzenlendi. logs.always_auto_scroll=Günlükleri her zaman otomatik kaydır logs.always_expand_running=Çalıştırma günlüklerini her zaman genişlet + [projects] deleted.display_name=Silinmiş Proje type-1.display_name=Kişisel Proje diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini index 1849d5d378..b3d0c37cbe 100644 --- a/options/locale/locale_uk-UA.ini +++ b/options/locale/locale_uk-UA.ini @@ -3428,6 +3428,7 @@ variables.update.success=Змінну відредаговано. logs.always_auto_scroll=Завжди автоматично прокручувати журнали logs.always_expand_running=Завжди розгортати поточні журнали + [projects] deleted.display_name=Видалений проєкт type-1.display_name=Індивідуальний проєкт diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 29eb6be949..9db58e71ac 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -3911,6 +3911,7 @@ variables.update.success=变量已编辑。 logs.always_auto_scroll=总是自动滚动日志 logs.always_expand_running=总是展开运行日志 + [projects] deleted.display_name=已删除项目 type-1.display_name=个人项目 diff --git a/options/locale/locale_zh-HK.ini b/options/locale/locale_zh-HK.ini index 8a8c2a7bb6..617977577d 100644 --- a/options/locale/locale_zh-HK.ini +++ b/options/locale/locale_zh-HK.ini @@ -980,6 +980,7 @@ runners.task_list.repository=儲存庫 + [projects] [git.filemode] diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index c7166bbd6c..74356c6cde 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -3554,6 +3554,7 @@ variables.update.failed=編輯變數失敗。 variables.update.success=已編輯變數。 + [projects] deleted.display_name=已刪除的專案 type-1.display_name=個人專案 From bc50431e8b8dbbc979084ca37af24e283d2c80c0 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 26 Oct 2025 06:52:01 -0700 Subject: [PATCH 003/103] Upgrade go mail to 0.7.2 (#35748) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cf4774801e..81187804a3 100644 --- a/go.mod +++ b/go.mod @@ -109,7 +109,7 @@ require ( github.com/ulikunitz/xz v0.5.15 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.4.1 - github.com/wneessen/go-mail v0.7.1 + github.com/wneessen/go-mail v0.7.2 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yohcop/openid-go v1.0.1 github.com/yuin/goldmark v1.7.13 diff --git a/go.sum b/go.sum index 9acef3b977..02a710e7f0 100644 --- a/go.sum +++ b/go.sum @@ -768,8 +768,8 @@ github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZ github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/wneessen/go-mail v0.7.1 h1:rvy63sp14N06/kdGqCYwW8Na5gDCXjTQM1E7So4PuKk= -github.com/wneessen/go-mail v0.7.1/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= From 2f309b844c829744e97d34c6ace11589e21786c2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 27 Oct 2025 05:26:38 +0800 Subject: [PATCH 004/103] Revert #18491, fix oauth2 client link account (#35745) Fix #35744 by reverting #18491 * "OpenID" options don't mean "OAuth2Client" options * "OAuth2(server)" options don't mean "OAuth2Client" options --- routers/web/web.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 43f104a73e..b5d283607a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -307,13 +307,6 @@ func registerWebRoutes(m *web.Router) { validation.AddBindingRules() - linkAccountEnabled := func(ctx *context.Context) { - if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled { - ctx.HTTPError(http.StatusForbidden) - return - } - } - openIDSignInEnabled := func(ctx *context.Context) { if !setting.Service.EnableOpenIDSignIn { ctx.HTTPError(http.StatusForbidden) @@ -545,9 +538,9 @@ func registerWebRoutes(m *web.Router) { }, openIDSignInEnabled) m.Get("/sign_up", auth.SignUp) m.Post("/sign_up", web.Bind(forms.RegisterForm{}), auth.SignUpPost) - m.Get("/link_account", linkAccountEnabled, auth.LinkAccount) - m.Post("/link_account_signin", linkAccountEnabled, web.Bind(forms.SignInForm{}), auth.LinkAccountPostSignIn) - m.Post("/link_account_signup", linkAccountEnabled, web.Bind(forms.RegisterForm{}), auth.LinkAccountPostRegister) + m.Get("/link_account", auth.LinkAccount) + m.Post("/link_account_signin", web.Bind(forms.SignInForm{}), auth.LinkAccountPostSignIn) + m.Post("/link_account_signup", web.Bind(forms.RegisterForm{}), auth.LinkAccountPostRegister) m.Group("/two_factor", func() { m.Get("", auth.TwoFactor) m.Post("", web.Bind(forms.TwoFactorAuthForm{}), auth.TwoFactorPost) @@ -622,7 +615,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/delete", security.DeleteOpenID) m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) }, openIDSignInEnabled) - m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink) + m.Post("/account_link", security.DeleteAccountLink) }) m.Group("/applications", func() { From 87d670c96b28b030f2eb42b6096533434ca1cd25 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Mon, 27 Oct 2025 00:39:11 +0000 Subject: [PATCH 005/103] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.ini | 8 ++++++++ options/locale/locale_ja-JP.ini | 4 ++++ options/locale/locale_pt-PT.ini | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index 8db1ef2f30..045fb14f8b 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -3914,6 +3914,14 @@ variables.update.success=Tá an t-athróg curtha in eagar. logs.always_auto_scroll=Logchomhaid scrollaithe uathoibríoch i gcónaí logs.always_expand_running=Leathnaigh logs reatha i gcónaí +general=Ginearálta +general.enable_actions=Cumasaigh Gníomhartha +general.collaborative_owners_management=Bainistíocht Chomhoibríoch Úinéirí +general.collaborative_owners_management_help=Is úsáideoir nó eagraíocht é úinéir comhoibríoch a bhfuil rochtain ag a stór príobháideach ar ghníomhartha agus ar shreafaí oibre an stórais sin. +general.add_collaborative_owner=Cuir Úinéir Comhoibríoch leis +general.collaborative_owner_not_exist=Níl an t-úinéir comhoibríoch ann. +general.remove_collaborative_owner=Bain Úinéir Comhoibríoch +general.remove_collaborative_owner_desc=Má bhaintear úinéir comhoibríoch, cuirfidh sé sin cosc ​​ar stórtha an úinéara rochtain a fháil ar na gníomhartha sa stór seo. An bhfuil tú ag iarraidh leanúint ar aghaidh? [projects] deleted.display_name=Tionscadal scriosta diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index 3dfd143d37..a0d4f6cb75 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1969,6 +1969,9 @@ pulls.status_checks_requested=必須 pulls.status_checks_details=詳細 pulls.status_checks_hide_all=すべてのチェックを隠す pulls.status_checks_show_all=すべてのチェックを表示 +pulls.status_checks_approve_all=ワークフローをすべて承認 +pulls.status_checks_need_approvals=%d 件のワークフローが承認待ちです +pulls.status_checks_need_approvals_helper=ワークフローはリポジトリ管理者が承認した後にのみ実行されます。 pulls.update_branch=マージでブランチを更新 pulls.update_branch_rebase=リベースでブランチを更新 pulls.update_branch_success=ブランチの更新が成功しました @@ -3890,6 +3893,7 @@ workflow.has_workflow_dispatch=このワークフローには workflow_dispatch workflow.has_no_workflow_dispatch=ワークフロー '%s' には workflow_dispatch イベントトリガーがありません。 need_approval_desc=フォークプルリクエストのワークフローを実行するには承認が必要です。 +approve_all_success=すべてのワークフローの実行が正常に承認されました。 variables=変数 variables.management=変数の管理 diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 39168bdaaa..95ac1fb5a4 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -3914,6 +3914,14 @@ variables.update.success=A variável foi editada. logs.always_auto_scroll=Rolar registos de forma automática e permanente logs.always_expand_running=Expandir sempre os registos que vão rolando +general=Geral +general.enable_actions=Habilitar Operações +general.collaborative_owners_management=Gestão de proprietários colaborativos +general.collaborative_owners_management_help=Um proprietário colaborativo é um utilizador ou uma organização cujo repositório privado tem acesso às operações e às sequências de trabalho deste repositório. +general.add_collaborative_owner=Adicionar proprietário colaborativo +general.collaborative_owner_not_exist=O proprietário colaborativo não existe. +general.remove_collaborative_owner=Remover proprietário colaborativo +general.remove_collaborative_owner_desc=A remoção de um proprietário colaborativo impedirá que os repositórios do proprietário acedam às operações neste repositório. Continuamos? [projects] deleted.display_name=Planeamento eliminado From cddff73bbdd9a1ab65661fe39420a1c08b908012 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 27 Oct 2025 22:45:07 +0800 Subject: [PATCH 006/103] Fix "ref-issue" handling in markup (#35739) This is a follow up for #35662, and also fix #31181, help #30275, fix #31161 --- templates/base/head_script.tmpl | 1 - web_src/js/components/ContextPopup.vue | 62 +++++++++++--------------- web_src/js/features/contextpopup.ts | 43 ------------------ web_src/js/features/repo-diff.ts | 4 +- web_src/js/features/repo-editor.ts | 2 - web_src/js/features/repo-issue-edit.ts | 3 -- web_src/js/index-domready.ts | 2 - web_src/js/markup/content.ts | 2 + web_src/js/markup/refissue.ts | 41 +++++++++++++++++ web_src/js/modules/tippy.ts | 4 ++ 10 files changed, 74 insertions(+), 90 deletions(-) delete mode 100644 web_src/js/features/contextpopup.ts create mode 100644 web_src/js/markup/refissue.ts diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl index f6648b59d8..daef7afd28 100644 --- a/templates/base/head_script.tmpl +++ b/templates/base/head_script.tmpl @@ -36,7 +36,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly. copy_success: {{ctx.Locale.Tr "copy_success"}}, copy_error: {{ctx.Locale.Tr "copy_error"}}, error_occurred: {{ctx.Locale.Tr "error.occurred"}}, - network_error: {{ctx.Locale.Tr "error.network_error"}}, remove_label_str: {{ctx.Locale.Tr "remove_label_str"}}, modal_confirm: {{ctx.Locale.Tr "modal.confirm"}}, modal_cancel: {{ctx.Locale.Tr "modal.cancel"}}, diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 5ec4499e48..aebfaa5d26 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -2,62 +2,53 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts'; -import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; -import type {IssuePathInfo} from '../types.ts'; +import {computed, onMounted, shallowRef} from 'vue'; -const {appSubUrl, i18n} = window.config; +const props = defineProps<{ + repoLink: string, + loadIssueInfoUrl: string, +}>(); const loading = shallowRef(false); const issue = shallowRef(null); const renderedLabels = shallowRef(''); -const i18nErrorOccurred = i18n.error_occurred; -const i18nErrorMessage = shallowRef(null); +const errorMessage = shallowRef(null); + +const createdAt = computed(() => { + return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}); +}); -const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})); const body = computed(() => { const body = issue.value.body.replace(/\n+/g, ' '); - if (body.length > 85) { - return `${body.substring(0, 85)}…`; - } - return body; + return body.length > 85 ? `${body.substring(0, 85)}…` : body; }); -const root = useTemplateRef('root'); - -onMounted(() => { - root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit) => { - if (!loading.value && issue.value === null) { - load(e.detail); - } - }); -}); - -async function load(issuePathInfo: IssuePathInfo) { +onMounted(async () => { loading.value = true; - i18nErrorMessage.value = null; - + errorMessage.value = null; try { - const response = await GET(`${appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`); // backend: GetIssueInfo - const respJson = await response.json(); - if (!response.ok) { - i18nErrorMessage.value = respJson.message ?? i18n.network_error; + const resp = await GET(props.loadIssueInfoUrl); + if (!resp.ok) { + errorMessage.value = resp.status ? resp.statusText : 'Unknown network error'; return; } + const respJson = await resp.json(); issue.value = respJson.convertedIssue; renderedLabels.value = respJson.renderedLabels; - } catch { - i18nErrorMessage.value = i18n.network_error; } finally { loading.value = false; } -} +}); diff --git a/web_src/js/features/contextpopup.ts b/web_src/js/features/contextpopup.ts deleted file mode 100644 index 7477331dbe..0000000000 --- a/web_src/js/features/contextpopup.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {createApp} from 'vue'; -import ContextPopup from '../components/ContextPopup.vue'; -import {parseIssueHref} from '../utils.ts'; -import {createTippy} from '../modules/tippy.ts'; - -export function initContextPopups() { - const refIssues = document.querySelectorAll('.ref-issue'); - attachRefIssueContextPopup(refIssues); -} - -export function attachRefIssueContextPopup(refIssues: NodeListOf) { - for (const refIssue of refIssues) { - if (refIssue.classList.contains('ref-external-issue')) continue; - - const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')); - if (!issuePathInfo.ownerName) continue; - - const el = document.createElement('div'); - el.classList.add('tw-p-3'); - refIssue.parentNode.insertBefore(el, refIssue.nextSibling); - - const view = createApp(ContextPopup); - - try { - view.mount(el); - } catch (err) { - console.error(err); - el.textContent = 'ContextPopup failed to load'; - } - - createTippy(refIssue, { - theme: 'default', - content: el, - placement: 'top-start', - interactive: true, - role: 'dialog', - interactiveBorder: 5, - onShow: () => { - el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: issuePathInfo})); - }, - }); - } -} diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 24d937a252..20cec2939d 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -12,8 +12,6 @@ import {invertFileFolding} from './file-fold.ts'; import {parseDom, sleep} from '../utils.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.ts'; -const {i18n} = window.config; - function initRepoDiffFileBox(el: HTMLElement) { // switch between "rendered" and "source", for image and CSV files queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => { @@ -86,7 +84,7 @@ function initRepoDiffConversationForm() { } } catch (error) { console.error('Error:', error); - showErrorToast(i18n.network_error); + showErrorToast(`Submit form failed: ${error}`); } finally { form?.classList.remove('is-loading'); } diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index f3ca13460c..0825999edc 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -1,7 +1,6 @@ import {html, htmlRaw} from '../utils/html.ts'; import {createCodeEditor} from './codeeditor.ts'; import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts'; -import {attachRefIssueContextPopup} from './contextpopup.ts'; import {POST} from '../modules/fetch.ts'; import {initDropzone} from './dropzone.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; @@ -199,5 +198,4 @@ export function initRepoEditor() { export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { // the content is from the server, so it is safe to use innerHTML previewPanel.innerHTML = html`
${htmlRaw(htmlContent)}
`; - attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue')); } diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index f883ee460b..43aee314e0 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -3,7 +3,6 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr import {POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} from '../utils/dom.ts'; -import {attachRefIssueContextPopup} from './contextpopup.ts'; import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; import {convertHtmlToMarkdown} from '../markup/html2markdown.ts'; import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts'; @@ -62,8 +61,6 @@ async function tryOnEditContent(e: DOMEvent) { renderContent = newRenderContent; rawContent.textContent = comboMarkdownEditor.value(); - const refIssues = renderContent.querySelectorAll('p .ref-issue'); - attachRefIssueContextPopup(refIssues); if (!commentContent.querySelector('.dropzone-attachments')) { if (data.attachments !== '') { diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index cfb6b89ea7..8a3a27fa19 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -5,7 +5,6 @@ import '../../node_modules/easymde/dist/easymde.min.css'; // TODO: lazy load in import {initHtmx} from './htmx.ts'; import {initDashboardRepoList} from './features/dashboard.ts'; import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; -import {initContextPopups} from './features/contextpopup.ts'; import {initRepoGraphGit} from './features/repo-graph.ts'; import {initHeatmap} from './features/heatmap.ts'; import {initImageDiff} from './features/imagediff.ts'; @@ -97,7 +96,6 @@ const initPerformanceTracer = callInitFunctions([ initHeadNavbarContentToggle, initFootLanguageMenu, - initContextPopups, initHeatmap, initImageDiff, initMarkupAnchors, diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index cf88ed61de..d964c88989 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -5,6 +5,7 @@ import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; import {registerGlobalSelectorFunc} from '../modules/observer.ts'; import {initMarkupRenderIframe} from './render-iframe.ts'; +import {initMarkupRefIssue} from './refissue.ts'; // code that runs for all markup content export function initMarkupContent(): void { @@ -15,5 +16,6 @@ export function initMarkupContent(): void { initMarkupCodeMath(el); initMarkupRenderAsciicast(el); initMarkupRenderIframe(el); + initMarkupRefIssue(el); }); } diff --git a/web_src/js/markup/refissue.ts b/web_src/js/markup/refissue.ts new file mode 100644 index 0000000000..5a05de84fe --- /dev/null +++ b/web_src/js/markup/refissue.ts @@ -0,0 +1,41 @@ +import {queryElems} from '../utils/dom.ts'; +import {parseIssueHref} from '../utils.ts'; +import {createApp} from 'vue'; +import ContextPopup from '../components/ContextPopup.vue'; +import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts'; + +export function initMarkupRefIssue(el: HTMLElement) { + queryElems(el, '.ref-issue', (el) => { + el.addEventListener('mouseenter', showMarkupRefIssuePopup); + el.addEventListener('focus', showMarkupRefIssuePopup); + }); +} + +export function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) { + const refIssue = e.currentTarget as HTMLElement; + if (getAttachedTippyInstance(refIssue)) return; + if (refIssue.classList.contains('ref-external-issue')) return; + + const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')); + if (!issuePathInfo.ownerName) return; + + const el = document.createElement('div'); + const tippy = createTippy(refIssue, { + theme: 'default', + content: el, + trigger: 'mouseenter focus', + placement: 'top-start', + interactive: true, + role: 'dialog', + interactiveBorder: 5, + // onHide() { return false }, // help to keep the popup and debug the layout + onShow: () => { + const view = createApp(ContextPopup, { + // backend: GetIssueInfo + loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`, + }); + view.mount(el); + }, + }); + tippy.show(); +} diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 2a1d998d76..6f42a4f987 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -209,3 +209,7 @@ export function showTemporaryTooltip(target: Element, content: Content): void { }, }); } + +export function getAttachedTippyInstance(el: Element): Instance | null { + return el._tippy ?? null; +} From 6b5563c54a05e3ed925822d3126da38fac241b08 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 28 Oct 2025 18:25:00 +0800 Subject: [PATCH 007/103] Support selecting theme on the footer (#35741) Fixes: https://github.com/go-gitea/gitea/pull/27576 --- modules/setting/session.go | 7 +-- modules/svg/svg.go | 3 + modules/templates/helper.go | 13 ---- modules/templates/util_render.go | 17 ++++++ modules/web/middleware/cookie.go | 5 +- .../img/svg/gitea-colorblind-redgreen.svg | 1 + public/assets/img/svg/gitea-eclipse.svg | 1 + routers/common/errpage.go | 2 +- routers/common/qos.go | 2 +- routers/install/routes.go | 5 ++ routers/web/misc/webtheme.go | 42 +++++++++++++ routers/web/user/setting/profile.go | 2 +- routers/web/web.go | 3 + services/context/context.go | 2 +- services/context/context_template.go | 23 +++++++- services/webtheme/webtheme.go | 59 +++++++++++++++---- services/webtheme/webtheme_test.go | 6 ++ templates/base/footer_content.tmpl | 4 ++ templates/base/head.tmpl | 2 +- templates/base/head_style.tmpl | 2 +- templates/status/500.tmpl | 4 +- templates/user/settings/appearance.tmpl | 18 ++++-- web_src/css/home.css | 25 +++++++- ...eme-gitea-auto-protanopia-deuteranopia.css | 4 +- web_src/css/themes/theme-gitea-auto.css | 1 + ...eme-gitea-dark-protanopia-deuteranopia.css | 4 +- web_src/css/themes/theme-gitea-dark.css | 1 + ...me-gitea-light-protanopia-deuteranopia.css | 4 +- web_src/css/themes/theme-gitea-light.css | 1 + web_src/js/features/common-page.ts | 31 ++++++++-- web_src/js/index-domready.ts | 5 +- web_src/svg/gitea-colorblind-redgreen.svg | 13 ++++ web_src/svg/gitea-eclipse.svg | 1 + 33 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 public/assets/img/svg/gitea-colorblind-redgreen.svg create mode 100644 public/assets/img/svg/gitea-eclipse.svg create mode 100644 routers/web/misc/webtheme.go create mode 100644 web_src/svg/gitea-colorblind-redgreen.svg create mode 100644 web_src/svg/gitea-eclipse.svg diff --git a/modules/setting/session.go b/modules/setting/session.go index 19a05ce2c2..cb9b6024ba 100644 --- a/modules/setting/session.go +++ b/modules/setting/session.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) // SessionConfig defines Session settings @@ -49,10 +50,8 @@ func loadSessionFrom(rootCfg ConfigProvider) { checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig) } SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea") - SessionConfig.CookiePath = AppSubURL - if SessionConfig.CookiePath == "" { - SessionConfig.CookiePath = "/" - } + // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath + SessionConfig.CookiePath = util.IfZero(AppSubURL, "/") SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://")) SessionConfig.Gclifetime = sec.Key("GC_INTERVAL_TIME").MustInt64(86400) SessionConfig.Maxlifetime = sec.Key("SESSION_LIFE_TIME").MustInt64(86400) diff --git a/modules/svg/svg.go b/modules/svg/svg.go index fded9d0873..333b5764c2 100644 --- a/modules/svg/svg.go +++ b/modules/svg/svg.go @@ -58,6 +58,9 @@ func MockIcon(icon string) func() { // RenderHTML renders icons - arguments icon name (string), size (int), class (string) func RenderHTML(icon string, others ...any) template.HTML { + if icon == "" { + return "" + } size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) if svgStr, ok := svgIcons[icon]; ok { // the code is somewhat hacky, but it just works, because the SVG contents are all normalized diff --git a/modules/templates/helper.go b/modules/templates/helper.go index e454bce4bd..a7aa321811 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -12,7 +12,6 @@ import ( "strings" "time" - user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup" @@ -21,7 +20,6 @@ import ( "code.gitea.io/gitea/modules/templates/eval" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/gitdiff" - "code.gitea.io/gitea/services/webtheme" ) // NewFuncMap returns functions for injecting to templates @@ -130,7 +128,6 @@ func NewFuncMap() template.FuncMap { "DisableWebhooks": func() bool { return setting.DisableWebhooks }, - "UserThemeName": userThemeName, "NotificationSettings": func() map[string]any { return map[string]any{ "MinTimeout": int(setting.UI.Notification.MinTimeout / time.Millisecond), @@ -217,16 +214,6 @@ func evalTokens(tokens ...any) (any, error) { return n.Value, err } -func userThemeName(user *user_model.User) string { - if user == nil || user.Theme == "" { - return setting.UI.DefaultTheme - } - if webtheme.IsThemeAvailable(user.Theme) { - return user.Theme - } - return setting.UI.DefaultTheme -} - func isQueryParamEmpty(v any) bool { return v == nil || v == false || v == 0 || v == int64(0) || v == "" } diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 1056c42643..132ca4d916 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -23,8 +23,10 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/webtheme" ) type RenderUtils struct { @@ -259,3 +261,18 @@ func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink strin htmlCode += "" return template.HTML(htmlCode) } + +func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML { + svgName := "octicon-paintbrush" + switch info.ColorScheme { + case "dark": + svgName = "octicon-moon" + case "light": + svgName = "octicon-sun" + case "auto": + svgName = "gitea-eclipse" + } + icon := svg.RenderHTML(svgName, iconSize) + extraIcon := svg.RenderHTML(info.GetExtraIconName(), iconSize) + return htmlutil.HTMLFormat(`
%s %s %s
`, info.GetDescription(), icon, info.DisplayName, extraIcon) +} diff --git a/modules/web/middleware/cookie.go b/modules/web/middleware/cookie.go index f2d25f5b1c..ad9aee6478 100644 --- a/modules/web/middleware/cookie.go +++ b/modules/web/middleware/cookie.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) // SetRedirectToCookie convenience function to set the RedirectTo cookie consistently @@ -39,11 +40,13 @@ func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) { // These are more specific than cookies without a trailing /, so // we need to delete these if they exist. deleteLegacySiteCookie(resp, name) + + // HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath cookie := &http.Cookie{ Name: name, Value: url.QueryEscape(value), MaxAge: maxAge, - Path: setting.SessionConfig.CookiePath, + Path: util.IfZero(setting.SessionConfig.CookiePath, "/"), Domain: setting.SessionConfig.Domain, Secure: setting.SessionConfig.Secure, HttpOnly: true, diff --git a/public/assets/img/svg/gitea-colorblind-redgreen.svg b/public/assets/img/svg/gitea-colorblind-redgreen.svg new file mode 100644 index 0000000000..5933afa850 --- /dev/null +++ b/public/assets/img/svg/gitea-colorblind-redgreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/img/svg/gitea-eclipse.svg b/public/assets/img/svg/gitea-eclipse.svg new file mode 100644 index 0000000000..eb90ad8f6b --- /dev/null +++ b/public/assets/img/svg/gitea-eclipse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/common/errpage.go b/routers/common/errpage.go index 9ca309931b..4caef92d14 100644 --- a/routers/common/errpage.go +++ b/routers/common/errpage.go @@ -35,7 +35,7 @@ func RenderPanicErrorPage(w http.ResponseWriter, req *http.Request, err any) { httpcache.SetCacheControlInHeader(w.Header(), &httpcache.CacheControlOptions{NoTransform: true}) w.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions) - tmplCtx := context.TemplateContext{} + tmplCtx := context.NewTemplateContext(req.Context(), req) tmplCtx["Locale"] = middleware.Locale(w, req) ctxData := middleware.GetContextData(req.Context()) diff --git a/routers/common/qos.go b/routers/common/qos.go index e50fbe4f69..0670ea0b4c 100644 --- a/routers/common/qos.go +++ b/routers/common/qos.go @@ -133,7 +133,7 @@ func renderServiceUnavailable(w http.ResponseWriter, req *http.Request) { return } - tmplCtx := giteacontext.TemplateContext{} + tmplCtx := giteacontext.NewTemplateContext(req.Context(), req) tmplCtx["Locale"] = middleware.Locale(w, req) ctxData := middleware.GetContextData(req.Context()) err := templates.HTMLRenderer().HTML(w, http.StatusServiceUnavailable, tplStatus503, ctxData, tmplCtx) diff --git a/routers/install/routes.go b/routers/install/routes.go index e4f833e751..d47c1f61ee 100644 --- a/routers/install/routes.go +++ b/routers/install/routes.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/routers/web/misc" "code.gitea.io/gitea/services/forms" ) @@ -32,7 +33,11 @@ func Routes() *web.Router { r.Get("/", Install) // it must be on the root, because the "install.js" use the window.location to replace the "localhost" AppURL r.Post("/", web.Bind(forms.InstallForm{}), SubmitInstall) r.Get("/post-install", InstallDone) + + r.Get("/-/web-theme/list", misc.WebThemeList) + r.Post("/-/web-theme/apply", misc.WebThemeApply) r.Get("/api/healthz", healthcheck.Check) + r.NotFound(installNotFound) base.Mount("", r) diff --git a/routers/web/misc/webtheme.go b/routers/web/misc/webtheme.go new file mode 100644 index 0000000000..076bdf8fda --- /dev/null +++ b/routers/web/misc/webtheme.go @@ -0,0 +1,42 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" + "code.gitea.io/gitea/services/webtheme" +) + +func WebThemeList(ctx *context.Context) { + curWebTheme := ctx.TemplateContext.CurrentWebTheme() + renderUtils := templates.NewRenderUtils(ctx) + allThemes := webtheme.GetAvailableThemes() + + var results []map[string]any + for _, theme := range allThemes { + results = append(results, map[string]any{ + "name": renderUtils.RenderThemeItem(theme, 14), + "value": theme.InternalName, + "class": "item js-aria-clickable" + util.Iif(theme.InternalName == curWebTheme.InternalName, " selected", ""), + }) + } + ctx.JSON(http.StatusOK, map[string]any{"results": results}) +} + +func WebThemeApply(ctx *context.Context) { + themeName := ctx.FormString("theme") + if ctx.Doer != nil { + opts := &user_service.UpdateOptions{Theme: optional.Some(themeName)} + _ = user_service.UpdateUser(ctx, ctx.Doer, opts) + } else { + middleware.SetSiteCookie(ctx.Resp, "gitea_theme", themeName, 0) + } +} diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 98995cd69c..27b0c83a38 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -369,7 +369,7 @@ func UpdateUIThemePost(ctx *context.Context) { return } - if !webtheme.IsThemeAvailable(form.Theme) { + if webtheme.GetThemeMetaInfo(form.Theme) == nil { ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) ctx.Redirect(setting.AppSubURL + "/user/settings/appearance") return diff --git a/routers/web/web.go b/routers/web/web.go index b5d283607a..8b55e4469e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -490,6 +490,9 @@ func registerWebRoutes(m *web.Router) { m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup) + m.Get("/-/web-theme/list", misc.WebThemeList) + m.Post("/-/web-theme/apply", optSignInIgnoreCsrf, misc.WebThemeApply) + m.Group("/explore", func() { m.Get("", func(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/explore/repos") diff --git a/services/context/context.go b/services/context/context.go index 4e83dee807..26b5bd3775 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -103,7 +103,7 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) { } func NewTemplateContextForWeb(ctx *Context) TemplateContext { - tmplCtx := NewTemplateContext(ctx) + tmplCtx := NewTemplateContext(ctx, ctx.Req) tmplCtx["Locale"] = ctx.Base.Locale tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) diff --git a/services/context/context_template.go b/services/context/context_template.go index 7878d409ca..c1045136ee 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -5,13 +5,16 @@ package context import ( "context" + "net/http" "time" + + "code.gitea.io/gitea/services/webtheme" ) var _ context.Context = TemplateContext(nil) -func NewTemplateContext(ctx context.Context) TemplateContext { - return TemplateContext{"_ctx": ctx} +func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext { + return TemplateContext{"_ctx": ctx, "_req": req} } func (c TemplateContext) parentContext() context.Context { @@ -33,3 +36,19 @@ func (c TemplateContext) Err() error { func (c TemplateContext) Value(key any) any { return c.parentContext().Value(key) } + +func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { + req := c["_req"].(*http.Request) + var themeName string + if webCtx := GetWebContext(c); webCtx != nil { + if webCtx.Doer != nil { + themeName = webCtx.Doer.Theme + } + } + if themeName == "" { + if cookieTheme, _ := req.Cookie("gitea_theme"); cookieTheme != nil { + themeName = cookieTheme.Value + } + } + return webtheme.GuaranteeGetThemeMetaInfo(themeName) +} diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 4e89d6dbac..72f01a76c7 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -17,9 +17,9 @@ import ( ) var ( - availableThemes []*ThemeMetaInfo - availableThemeInternalNames container.Set[string] - themeOnce sync.Once + availableThemes []*ThemeMetaInfo + availableThemeMap map[string]*ThemeMetaInfo + themeOnce sync.Once ) const ( @@ -28,9 +28,25 @@ const ( ) type ThemeMetaInfo struct { - FileName string - InternalName string - DisplayName string + FileName string + InternalName string + DisplayName string + ColorblindType string + ColorScheme string +} + +func (info *ThemeMetaInfo) GetDescription() string { + if info.ColorblindType == "red-green" { + return "Red-green colorblind friendly" + } + return "" +} + +func (info *ThemeMetaInfo) GetExtraIconName() string { + if info.ColorblindType == "red-green" { + return "gitea-colorblind-redgreen" + } + return "" } func parseThemeMetaInfoToMap(cssContent string) map[string]string { @@ -54,7 +70,7 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { |('(\\'|[^'])*') |([^'";]+) ) -\s*; +\s*;? \s* ) ` @@ -102,17 +118,19 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } themeInfo.DisplayName = m["--theme-display-name"] + themeInfo.ColorblindType = m["--theme-colorblind-type"] + themeInfo.ColorScheme = m["--theme-color-scheme"] return themeInfo } func initThemes() { availableThemes = nil defer func() { - availableThemeInternalNames = container.Set[string]{} + availableThemeMap = map[string]*ThemeMetaInfo{} for _, theme := range availableThemes { - availableThemeInternalNames.Add(theme.InternalName) + availableThemeMap[theme.InternalName] = theme } - if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) { + if availableThemeMap[setting.UI.DefaultTheme] == nil { setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme) } }() @@ -147,6 +165,9 @@ func initThemes() { if availableThemes[i].InternalName == setting.UI.DefaultTheme { return true } + if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType { + return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType + } return availableThemes[i].DisplayName < availableThemes[j].DisplayName }) if len(availableThemes) == 0 { @@ -160,7 +181,21 @@ func GetAvailableThemes() []*ThemeMetaInfo { return availableThemes } -func IsThemeAvailable(internalName string) bool { +func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { themeOnce.Do(initThemes) - return availableThemeInternalNames.Contains(internalName) + return availableThemeMap[internalName] +} + +// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, +// to simplify the caller's logic, especially for templates. +// There are already enough warnings messages if the default theme is not available. +func GuaranteeGetThemeMetaInfo(internalName string) *ThemeMetaInfo { + info := GetThemeMetaInfo(internalName) + if info == nil { + info = GetThemeMetaInfo(setting.UI.DefaultTheme) + } + if info == nil { + info = &ThemeMetaInfo{DisplayName: "unavailable", InternalName: "unavailable", FileName: "unavailable"} + } + return info } diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go index 587953ab0c..d6c014fabf 100644 --- a/services/webtheme/webtheme_test.go +++ b/services/webtheme/webtheme_test.go @@ -34,4 +34,10 @@ gitea-theme-meta-info { --k2: real; }`) assert.Equal(t, map[string]string{"--k2": "real"}, m) + + // compressed CSS, no trailing semicolon + m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1"}`) + assert.Equal(t, map[string]string{"--k1": "v1"}, m) + m = parseThemeMetaInfoToMap(`gitea-theme-meta-info{--k1:"v1";--k2:"v2"}`) + assert.Equal(t, map[string]string{"--k1": "v1", "--k2": "v2"}, m) } diff --git a/templates/base/footer_content.tmpl b/templates/base/footer_content.tmpl index 60eb2fe1f8..df437badf6 100644 --- a/templates/base/footer_content.tmpl +++ b/templates/base/footer_content.tmpl @@ -17,6 +17,10 @@ {{end}}