diff --git a/MAINTAINERS b/MAINTAINERS index 1c7afc6f6c7..26192eff447 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -64,3 +64,4 @@ metiftikci (@metiftikci) Christopher Homberger (@ChristopherHX) Tobias Balle-Petersen (@tobiasbp) TheFox (@TheFox0x7) +Nicolas (@bircni) \ No newline at end of file diff --git a/cmd/hook.go b/cmd/hook.go index 4f6492b0f05..a0280e283f7 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -194,7 +194,7 @@ Gitea or set your environment appropriately.`, "") userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64) - actionPerm, _ := strconv.Atoi(os.Getenv(repo_module.EnvActionPerm)) + actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64) hookOptions := private.HookOptions{ UserID: userID, @@ -204,7 +204,7 @@ Gitea or set your environment appropriately.`, "") GitPushOptions: pushOptions(), PullRequestID: prID, DeployKeyID: deployKeyID, - ActionPerm: actionPerm, + ActionsTaskID: actionsTaskID, IsWiki: isWiki, } diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 9297f3d0627..803231ff128 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1162,6 +1162,9 @@ LEVEL = Info ;; Delay mergeable check until page view or API access, for pull requests that have not been updated in the specified days when their base branches get updated. ;; Use "-1" to always check all pull requests (old behavior). Use "0" to always delay the checks. ;DELAY_CHECK_FOR_INACTIVE_DAYS = 7 +;; +;; Set the default value for "Delete pull request branch after merge by default" for new repositories +;DEFAULT_DELETE_BRANCH_AFTER_MERGE = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/eslint.config.ts b/eslint.config.ts index 8ed0cf789a7..9f98adf8596 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,5 +1,6 @@ import arrayFunc from 'eslint-plugin-array-func'; import comments from '@eslint-community/eslint-plugin-eslint-comments'; +import deMorgan from 'eslint-plugin-de-morgan'; import github from 'eslint-plugin-github'; import globals from 'globals'; import importPlugin from 'eslint-plugin-import-x'; @@ -63,6 +64,7 @@ export default defineConfig([ '@stylistic': stylistic, '@typescript-eslint': typescriptPlugin.plugin, 'array-func': arrayFunc, + 'de-morgan': deMorgan, 'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203 regexp, sonarjs, @@ -179,7 +181,7 @@ export default defineConfig([ '@typescript-eslint/naming-convention': [0], '@typescript-eslint/no-array-constructor': [2], '@typescript-eslint/no-array-delete': [2], - '@typescript-eslint/no-base-to-string': [0], + '@typescript-eslint/no-base-to-string': [2], '@typescript-eslint/no-confusing-non-null-assertion': [2], '@typescript-eslint/no-confusing-void-expression': [0], '@typescript-eslint/no-deprecated': [2], @@ -254,10 +256,10 @@ export default defineConfig([ '@typescript-eslint/prefer-function-type': [2], '@typescript-eslint/prefer-includes': [2], '@typescript-eslint/prefer-literal-enum-member': [0], - '@typescript-eslint/prefer-namespace-keyword': [0], + '@typescript-eslint/prefer-namespace-keyword': [2], '@typescript-eslint/prefer-nullish-coalescing': [0], '@typescript-eslint/prefer-optional-chain': [2, {requireNullish: true}], - '@typescript-eslint/prefer-promise-reject-errors': [0], + '@typescript-eslint/prefer-promise-reject-errors': [2], '@typescript-eslint/prefer-readonly': [0], '@typescript-eslint/prefer-readonly-parameter-types': [0], '@typescript-eslint/prefer-reduce-type-parameter': [0], @@ -268,7 +270,7 @@ export default defineConfig([ '@typescript-eslint/require-array-sort-compare': [0], '@typescript-eslint/require-await': [0], '@typescript-eslint/restrict-plus-operands': [2], - '@typescript-eslint/restrict-template-expressions': [0], + '@typescript-eslint/restrict-template-expressions': [2], '@typescript-eslint/return-await': [0], '@typescript-eslint/strict-boolean-expressions': [0], '@typescript-eslint/strict-void-return': [0], @@ -295,6 +297,8 @@ export default defineConfig([ 'consistent-this': [0], 'constructor-super': [2], 'curly': [0], + 'de-morgan/no-negated-conjunction': [2], + 'de-morgan/no-negated-disjunction': [2], 'default-case-last': [2], 'default-case': [0], 'default-param-last': [0], @@ -586,6 +590,7 @@ export default defineConfig([ 'no-undef-init': [2], 'no-undef': [2], // it is still needed by eslint & IDE to prompt undefined names in real time 'no-undefined': [0], + 'no-unassigned-vars': [2], 'no-underscore-dangle': [0], 'no-unexpected-multiline': [2], 'no-unmodified-loop-condition': [2], @@ -626,19 +631,20 @@ export default defineConfig([ 'prefer-numeric-literals': [2], 'prefer-object-has-own': [2], 'prefer-object-spread': [2], - 'prefer-promise-reject-errors': [2, {allowEmptyReject: false}], + 'prefer-promise-reject-errors': [0], // handled by @typescript-eslint/prefer-promise-reject-errors 'prefer-regex-literals': [2], 'prefer-rest-params': [2], 'prefer-spread': [2], 'prefer-template': [2], - 'radix': [2, 'as-needed'], + 'preserve-caught-error': [0], + 'radix': [0], 'regexp/confusing-quantifier': [2], 'regexp/control-character-escape': [2], 'regexp/hexadecimal-escape': [0], 'regexp/letter-case': [0], 'regexp/match-any': [2], 'regexp/negation': [2], - 'regexp/no-contradiction-with-assertion': [0], + 'regexp/no-contradiction-with-assertion': [2], 'regexp/no-control-character': [0], 'regexp/no-dupe-characters-character-class': [2], 'regexp/no-dupe-disjunctions': [2], @@ -654,8 +660,8 @@ export default defineConfig([ 'regexp/no-invisible-character': [2], 'regexp/no-lazy-ends': [2], 'regexp/no-legacy-features': [2], - 'regexp/no-misleading-capturing-group': [0], - 'regexp/no-misleading-unicode-character': [0], + 'regexp/no-misleading-capturing-group': [2], + 'regexp/no-misleading-unicode-character': [2], 'regexp/no-missing-g-flag': [2], 'regexp/no-non-standard-flag': [2], 'regexp/no-obscure-range': [2], @@ -991,6 +997,7 @@ export default defineConfig([ 'vitest/require-top-level-describe': [0], 'vitest/valid-describe-callback': [2], 'vitest/valid-expect': [2, {maxArgs: 2}], + 'vitest/valid-expect-in-promise': [2], 'vitest/valid-title': [2], }, }, diff --git a/flake.lock b/flake.lock index 3af57938421..8c7ac0c1960 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "lastModified": 1773821835, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", "owner": "nixos", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", "type": "github" }, "original": { diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 757bd13acd7..ec5cc0e32f1 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -170,10 +170,10 @@ type ActionArtifactMeta struct { } // ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run -func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) { +func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) { arts := make([]*ActionArtifactMeta, 0, 10) return arts, db.GetEngine(ctx).Table("action_artifact"). - Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). + Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). GroupBy("artifact_name"). Select("artifact_name, sum(file_size) as file_size, max(status) as status"). Find(&arts) diff --git a/models/actions/config.go b/models/actions/config.go new file mode 100644 index 00000000000..4f5357c5605 --- /dev/null +++ b/models/actions/config.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + + "xorm.io/xorm/convert" +) + +// OwnerActionsConfig defines the Actions configuration for a user or organization +type OwnerActionsConfig struct { + // TokenPermissionMode defines the default permission mode (permissive, restricted) + TokenPermissionMode repo_model.ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + + // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context. + MaxTokenPermissions *repo_model.ActionsTokenPermissions `json:"max_token_permissions,omitempty"` + + // AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo + AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"` +} + +var _ convert.ConversionFrom = (*OwnerActionsConfig)(nil) + +func (cfg *OwnerActionsConfig) FromDB(bytes []byte) error { + _ = json.Unmarshal(bytes, cfg) + cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode) + return nil +} + +// GetOwnerActionsConfig loads the OwnerActionsConfig for a user or organization from user settings +// It returns a default config if no setting is found +func GetOwnerActionsConfig(ctx context.Context, userID int64) (ret OwnerActionsConfig, err error) { + return user_model.GetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, ret) +} + +// SetOwnerActionsConfig saves the OwnerActionsConfig for a user or organization to user settings +func SetOwnerActionsConfig(ctx context.Context, userID int64, cfg OwnerActionsConfig) error { + return user_model.SetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, cfg) +} + +// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode. +func (cfg *OwnerActionsConfig) GetDefaultTokenPermissions() repo_model.ActionsTokenPermissions { + switch cfg.TokenPermissionMode { + case repo_model.ActionsTokenPermissionModeRestricted: + return repo_model.MakeRestrictedPermissions() + case repo_model.ActionsTokenPermissionModePermissive: + return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) + default: + return repo_model.MakeActionsTokenPermissions(perm.AccessModeNone) + } +} + +// GetMaxTokenPermissions returns the maximum allowed permissions +func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenPermissions { + if cfg.MaxTokenPermissions != nil { + return *cfg.MaxTokenPermissions + } + // Default max is write for everything + return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite) +} + +// ClampPermissions ensures that the given permissions don't exceed the maximum +func (cfg *OwnerActionsConfig) ClampPermissions(perms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions { + maxPerms := cfg.GetMaxTokenPermissions() + return repo_model.ClampActionsTokenPermissions(perms, maxPerms) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index c752e61b7d7..616e298dc97 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -51,6 +51,11 @@ type ActionRunJob struct { ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress + // TokenPermissions stores the explicit permissions from workflow/job YAML (no org/repo clamps applied). + // Org/repo clamps are enforced when the token is used at runtime. + // It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified. + TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` diff --git a/models/actions/token_permissions.go b/models/actions/token_permissions.go new file mode 100644 index 00000000000..985f6cc97be --- /dev/null +++ b/models/actions/token_permissions.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" +) + +// ComputeTaskTokenPermissions computes the effective permissions for a job token against the target repository. +// It uses the job's stored permissions (if any), then applies org/repo clamps and fork/cross-repo restrictions. +// Note: target repository access policy checks are enforced in GetActionsUserRepoPermission; this function only computes the job token's effective permission ceiling. +func ComputeTaskTokenPermissions(ctx context.Context, task *ActionTask, targetRepo *repo_model.Repository) (ret repo_model.ActionsTokenPermissions, err error) { + if err := task.LoadJob(ctx); err != nil { + return ret, err + } + if err := task.Job.LoadRepo(ctx); err != nil { + return ret, err + } + runRepo := task.Job.Repo + + if err := runRepo.LoadOwner(ctx); err != nil { + return ret, err + } + + repoActionsCfg := runRepo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + ownerActionsCfg, err := GetOwnerActionsConfig(ctx, runRepo.OwnerID) + if err != nil { + return ret, err + } + + var jobDeclaredPerms repo_model.ActionsTokenPermissions + if task.Job.TokenPermissions != nil { + jobDeclaredPerms = *task.Job.TokenPermissions + } else if repoActionsCfg.OverrideOwnerConfig { + jobDeclaredPerms = repoActionsCfg.GetDefaultTokenPermissions() + } else { + jobDeclaredPerms = ownerActionsCfg.GetDefaultTokenPermissions() + } + + var effectivePerms repo_model.ActionsTokenPermissions + if repoActionsCfg.OverrideOwnerConfig { + effectivePerms = repoActionsCfg.ClampPermissions(jobDeclaredPerms) + } else { + effectivePerms = ownerActionsCfg.ClampPermissions(jobDeclaredPerms) + } + + // Cross-repository access and fork pull requests are strictly read-only for security. + // This ensures a "task repo" cannot gain write access to other repositories via CrossRepoAccess settings. + isSameRepo := task.Job.RepoID == targetRepo.ID + restrictCrossRepoAccess := task.IsForkPullRequest || !isSameRepo + if restrictCrossRepoAccess { + effectivePerms = repo_model.ClampActionsTokenPermissions(effectivePerms, repo_model.MakeRestrictedPermissions()) + } + + return effectivePerms, nil +} diff --git a/models/fixtures/badge.yml b/models/fixtures/badge.yml new file mode 100644 index 00000000000..438cd0ca5d4 --- /dev/null +++ b/models/fixtures/badge.yml @@ -0,0 +1,5 @@ +- + id: 1 + slug: badge1 + description: just a test badge + image_url: badge1.png diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c1d448577c6..bb8dad5ec6d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -402,6 +402,8 @@ func prepareMigrationTasks() []*migration { newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments), newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID), newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), + newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), + newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), } return preparedMigrations } diff --git a/models/migrations/v1_26/v328.go b/models/migrations/v1_26/v328.go new file mode 100644 index 00000000000..81047305289 --- /dev/null +++ b/models/migrations/v1_26/v328.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func AddTokenPermissionsToActionRunJob(x *xorm.Engine) error { + type ActionRunJob struct { + TokenPermissions string `xorm:"JSON TEXT"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob)) + return err +} diff --git a/models/migrations/v1_26/v329.go b/models/migrations/v1_26/v329.go new file mode 100644 index 00000000000..1afd155f05e --- /dev/null +++ b/models/migrations/v1_26/v329.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type UserBadge struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices +} + +// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table +// and it replaces an old index on user_id +func AddUniqueIndexForUserBadge(x *xorm.Engine) error { + // remove possible duplicated records in table user_badge + type result struct { + UserID int64 + BadgeID int64 + Cnt int + } + var results []result + if err := x.Select("user_id, badge_id, count(*) as cnt"). + Table("user_badge"). + GroupBy("user_id, badge_id"). + Having("count(*) > 1"). + Find(&results); err != nil { + return err + } + for _, r := range results { + if x.Dialect().URI().DBType == schemas.MSSQL { + if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil { + return err + } + } else { + var ids []int64 + if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil { + return err + } + if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil { + return err + } + } + } + + return x.Sync(new(UserBadge)) +} diff --git a/models/migrations/v1_26/v329_test.go b/models/migrations/v1_26/v329_test.go new file mode 100644 index 00000000000..cab8e79906d --- /dev/null +++ b/models/migrations/v1_26/v329_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" +) + +type UserBadgeBefore struct { + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 `xorm:"INDEX"` +} + +func (UserBadgeBefore) TableName() string { + return "user_badge" +} + +func Test_AddUniqueIndexForUserBadge(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore)) + defer deferable() + if x == nil || t.Failed() { + return + } + + testData := []*UserBadgeBefore{ + {UserID: 1, BadgeID: 1}, + {UserID: 1, BadgeID: 1}, // duplicate + {UserID: 2, BadgeID: 1}, + {UserID: 1, BadgeID: 2}, + {UserID: 3, BadgeID: 3}, + {UserID: 3, BadgeID: 3}, // duplicate + } + + for _, data := range testData { + _, err := x.Insert(data) + assert.NoError(t, err) + } + + // check that we have duplicates + count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + totalCount, err := x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(6), totalCount) + + // run the migration + if err := AddUniqueIndexForUserBadge(x); err != nil { + assert.NoError(t, err) + return + } + + // verify the duplicates were removed + count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + // check total count + totalCount, err = x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(4), totalCount) + + // fail to insert a duplicate + _, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1}) + assert.Error(t, err) + + // succeed adding a non-duplicate + _, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1}) + assert.NoError(t, err) +} diff --git a/models/perm/access/actions_repo_permission_test.go b/models/perm/access/actions_repo_permission_test.go new file mode 100644 index 00000000000..442f6cf2fc2 --- /dev/null +++ b/models/perm/access/actions_repo_permission_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package access + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + perm_model "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetActionsUserRepoPermission(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // Use fixtures for repos and users + repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // Public, Owner 5, has Actions unit + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // Private, Owner 2, no Actions unit in fixtures + repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // Private, Owner 2, no Actions unit in fixtures + owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + actionsUser := user_model.NewActionsUser() + + // Ensure repo2 and repo15 have Actions units for testing configuration + for _, r := range []*repo_model.Repository{repo2, repo15} { + require.NoError(t, db.Insert(ctx, &repo_model.RepoUnit{ + RepoID: r.ID, + Type: unit.TypeActions, + Config: &repo_model.ActionsConfig{}, + })) + } + + t.Run("SameRepo_Public", func(t *testing.T) { + task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) + require.Equal(t, repo4.ID, task47.RepoID) + + perm, err := GetActionsUserRepoPermission(ctx, repo4, actionsUser, task47.ID) + require.NoError(t, err) + + // Public repo, bot should have Read access even if not collaborator + assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) + assert.True(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("SameRepo_Private", func(t *testing.T) { + // Use Task 53 which is already in Repo 2 (Private) + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + require.Equal(t, repo2.ID, task53.RepoID) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Private repo, bot has no base access, but gets Write from effective tokens perms (Permissive by default) + assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) + assert.True(t, perm.CanWrite(unit.TypeCode)) + }) + + t.Run("CrossRepo_Denied_None", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + + // Set owner policy to nil allowed repos (None) + cfg := actions_model.OwnerActionsConfig{} + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg)) + + perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID) + require.NoError(t, err) + + // Should NOT have access to the private repo. + assert.False(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("ForkPR_NoCrossRepo", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + task53.IsForkPullRequest = true + require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request")) + + // Policy contains repo15 + cfg := actions_model.OwnerActionsConfig{ + AllowedCrossRepoIDs: []int64{repo15.ID}, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg)) + + perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID) + require.NoError(t, err) + + // Fork PR never gets cross-repo access to other private repos + assert.False(t, perm.CanRead(unit.TypeCode)) + }) + + t.Run("Inheritance_And_Clamping", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + task53.IsForkPullRequest = false + require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request")) + + // Owner policy: Restricted mode (Read-only Code) + ownerCfg := actions_model.OwnerActionsConfig{ + TokenPermissionMode: repo_model.ActionsTokenPermissionModeRestricted, + MaxTokenPermissions: &repo_model.ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm_model.AccessMode{ + unit.TypeCode: perm_model.AccessModeRead, + }, + }, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg)) + + // Repo policy: OverrideOwnerConfig = false (should inherit owner's restricted mode) + repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions) + repo2ActionsCfg := repo2ActionsUnit.ActionsConfig() + repo2ActionsCfg.OverrideOwnerConfig = false + require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit)) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Should be clamped to Read-only + assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode)) + assert.False(t, perm.CanWrite(unit.TypeCode)) + }) + + t.Run("RepoOverride_Clamping", func(t *testing.T) { + task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53}) + + // Owner policy: Permissive (Write access) + ownerCfg := actions_model.OwnerActionsConfig{ + TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive, + } + require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg)) + + // Repo policy: OverrideOwnerConfig = true, MaxTokenPermissions = Read + repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions) + repo2ActionsCfg := repo2ActionsUnit.ActionsConfig() + repo2ActionsCfg.OverrideOwnerConfig = true + repo2ActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted + repo2ActionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm_model.AccessMode{ + unit.TypeCode: perm_model.AccessModeRead, + }, + } + require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit)) + + perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID) + require.NoError(t, err) + + // Should be clamped to Read-only + assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode)) + }) +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 3235d83203c..622fa5d99ab 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "slices" "strings" @@ -258,6 +259,23 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) { } } +func checkSameOwnerCrossRepoAccess(ctx context.Context, taskRepo, targetRepo *repo_model.Repository, isForkPR bool) bool { + if isForkPR { + // Fork PRs are never allowed cross-repo access to other private repositories of the owner. + return false + } + if taskRepo.OwnerID != targetRepo.OwnerID { + return false + } + ownerCfg, err := actions_model.GetOwnerActionsConfig(ctx, targetRepo.OwnerID) + if err != nil { + log.Error("GetOwnerActionsConfig: %v", err) + return false + } + + return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID) +} + // GetActionsUserRepoPermission returns the actions user permissions to the repository func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) { if actionsUser.ID != user_model.ActionsUserID { @@ -268,37 +286,96 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito return perm, err } - var accessMode perm_model.AccessMode + if err := task.LoadJob(ctx); err != nil { + return perm, err + } + + var taskRepo *repo_model.Repository if task.RepoID != repo.ID { - taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) - if err != nil || !exist { + if err := task.Job.LoadRepo(ctx); err != nil { 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 should owner's visibility also be considered here? + taskRepo = task.Job.Repo + } else { + taskRepo = repo + } - // check permission like simple user but limit to read-only - perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + // Compute effective permissions for this task against the target repo + effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo) + if err != nil { + return perm, err + } + if task.RepoID != repo.ID { + // Cross-repo access must also respect the target repo's permission ceiling. + targetRepoActionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if targetRepoActionsCfg.OverrideOwnerConfig { + effectivePerms = targetRepoActionsCfg.ClampPermissions(effectivePerms) + } else { + targetRepoOwnerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, repo.OwnerID) if err != nil { return perm, err } - perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead) - return perm, nil + effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms) } - accessMode = perm_model.AccessModeRead - } else if task.IsForkPullRequest { - accessMode = perm_model.AccessModeRead - } else { - accessMode = perm_model.AccessModeWrite } if err := repo.LoadUnits(ctx); err != nil { return perm, err } - perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + + var maxPerm Permission + + // Set up per-unit access modes based on configured permissions + maxPerm.units = repo.Units + maxPerm.unitsMode = maps.Clone(effectivePerms.UnitAccessModes) + + // Check permission like simple user but limit to read-only (PR #36095) + // Enhanced to also grant read-only access if isSameRepo is true and target repository is public + botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + if botPerm.AccessMode >= perm_model.AccessModeRead { + // Public repo allows read access, increase permissions to at least read + // Otherwise you cannot access your own repository if your permissions are set to none but the repository is public + for _, u := range repo.Units { + if botPerm.CanRead(u.Type) { + maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead) + } + } + } + + if task.RepoID == repo.ID { + return maxPerm, nil + } + + if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) { + // Access allowed by owner policy (grants access to private repos). + // Note: maxPerm has already been restricted to Read-Only in ComputeTaskTokenPermissions + // because isSameRepo is false. + return maxPerm, nil + } + + // Fall through to allow public repository read access via botPerm check below + + // Check if the repo is public or the Bot has explicit access + if botPerm.AccessMode >= perm_model.AccessModeRead { + return maxPerm, nil + } + + // Check Collaborative Owner and explicit Bot permissions + // We allow access if: + // 1. It's a collaborative owner relationship + // 2. The Actions Bot user has been explicitly granted access and repository is private + // 3. The repository is public (handled by botPerm above) + + if taskRepo.IsPrivate { + actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) + if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) { + return maxPerm, nil + } + } + return perm, nil } diff --git a/models/repo/pull_request_default_test.go b/models/repo/pull_request_default_test.go index 1c4f585ed90..b1653f2f1ae 100644 --- a/models/repo/pull_request_default_test.go +++ b/models/repo/pull_request_default_test.go @@ -26,7 +26,7 @@ func TestDefaultTargetBranchSelection(t *testing.T) { prConfig := prUnit.PullRequestsConfig() prConfig.DefaultTargetBranch = "branch2" prUnit.Config = prConfig - assert.NoError(t, UpdateRepoUnit(ctx, prUnit)) + assert.NoError(t, UpdateRepoUnitConfig(ctx, prUnit)) repo.Units = nil assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx)) } diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 811f83c9997..e927174a55f 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -778,3 +778,11 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor repos := make(RepositoryList, 0, opts.PageSize) return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos) } + +func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) { + if len(repoIDs) == 0 { + return RepositoryList{}, nil + } + repos := make(RepositoryList, 0, len(repoIDs)) + return repos, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).In("id", repoIDs).Find(&repos) +} diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index 491e96770c4..18ebd6be541 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -5,8 +5,6 @@ package repo import ( "context" - "slices" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" @@ -144,6 +142,7 @@ func DefaultPullRequestsConfig() *PullRequestsConfig { AllowRebaseUpdate: true, DefaultAllowMaintainerEdit: true, } + cfg.DefaultDeleteBranchAfterMerge = setting.Repository.PullRequest.DefaultDeleteBranchAfterMerge cfg.DefaultMergeStyle = MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle) cfg.DefaultMergeStyle = util.IfZero(cfg.DefaultMergeStyle, MergeStyleMerge) return cfg @@ -175,57 +174,6 @@ func DefaultPullRequestsUnit(repoID int64) RepoUnit { return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()} } -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) { - cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file) -} - -func (cfg *ActionsConfig) ToString() string { - return strings.Join(cfg.DisabledWorkflows, ",") -} - -func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool { - return slices.Contains(cfg.DisabledWorkflows, file) -} - -func (cfg *ActionsConfig) DisableWorkflow(file string) { - if slices.Contains(cfg.DisabledWorkflows, file) { - return - } - - 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) -} - -// ToDB exports a ActionsConfig to a serialized format. -func (cfg *ActionsConfig) ToDB() ([]byte, error) { - return json.Marshal(cfg) -} - // ProjectsMode represents the projects enabled for a repository type ProjectsMode string @@ -279,7 +227,8 @@ func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool { func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { switch colName { case "type": - switch unit.Type(db.Cell2Int64(val)) { + r.Type = unit.Type(db.Cell2Int64(val)) + switch r.Type { case unit.TypeExternalWiki: r.Config = new(ExternalWikiConfig) case unit.TypeExternalTracker: @@ -297,6 +246,11 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { default: r.Config = new(UnitConfig) } + case "config": + if *val == nil { + // XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields + _ = r.Config.FromDB(nil) + } } } @@ -360,9 +314,9 @@ func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err return units, nil } -// UpdateRepoUnit updates the provided repo unit -func UpdateRepoUnit(ctx context.Context, unit *RepoUnit) error { - _, err := db.GetEngine(ctx).ID(unit.ID).Update(unit) +// UpdateRepoUnitConfig updates the config of the provided repo unit +func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error { + _, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit) return err } diff --git a/models/repo/repo_unit_actions.go b/models/repo/repo_unit_actions.go new file mode 100644 index 00000000000..50e2925792a --- /dev/null +++ b/models/repo/repo_unit_actions.go @@ -0,0 +1,153 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "slices" + + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" +) + +// ActionsTokenPermissionMode defines the default permission mode for Actions tokens +type ActionsTokenPermissionMode string + +const ( + // ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible) + ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive" + // ActionsTokenPermissionModeRestricted - read access by default + ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted" +) + +func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode { + return []ActionsTokenPermissionMode{ActionsTokenPermissionModePermissive /* default */, ActionsTokenPermissionModeRestricted} +} + +// ActionsTokenPermissions defines the permissions for different repository units +type ActionsTokenPermissions struct { + UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"` +} + +var ActionsTokenUnitTypes = []unit.Type{ + unit.TypeCode, + unit.TypeIssues, + unit.TypePullRequests, + unit.TypePackages, + unit.TypeActions, + unit.TypeWiki, + unit.TypeReleases, + unit.TypeProjects, +} + +func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTokenPermissions) { + ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode) + for _, u := range ActionsTokenUnitTypes { + ret.UnitAccessModes[u] = unitAccessMode + } + return ret +} + +// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum +func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) { + ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode) + for _, ut := range ActionsTokenUnitTypes { + ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut]) + } + return ret +} + +// MakeRestrictedPermissions returns the restricted permissions +func MakeRestrictedPermissions() ActionsTokenPermissions { + ret := MakeActionsTokenPermissions(perm.AccessModeNone) + ret.UnitAccessModes[unit.TypeCode] = perm.AccessModeRead + ret.UnitAccessModes[unit.TypePackages] = perm.AccessModeRead + ret.UnitAccessModes[unit.TypeReleases] = perm.AccessModeRead + return ret +} + +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 + // TokenPermissionMode defines the default permission mode (permissive, restricted, or custom) + TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"` + // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context. + // Workflow YAML "permissions" keywords can reduce permissions but never exceed this ceiling. + MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"` + // OverrideOwnerConfig indicates if this repository should override the owner-level configuration (User or Org) + OverrideOwnerConfig bool `json:"override_owner_config,omitempty"` +} + +func (cfg *ActionsConfig) EnableWorkflow(file string) { + cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file) +} + +func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool { + return slices.Contains(cfg.DisabledWorkflows, file) +} + +func (cfg *ActionsConfig) DisableWorkflow(file string) { + if slices.Contains(cfg.DisabledWorkflows, file) { + return + } + + 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) +} + +// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode. +// It does not apply MaxTokenPermissions; callers must clamp if needed. +func (cfg *ActionsConfig) GetDefaultTokenPermissions() ActionsTokenPermissions { + switch cfg.TokenPermissionMode { + case ActionsTokenPermissionModeRestricted: + return MakeRestrictedPermissions() + case ActionsTokenPermissionModePermissive: + return MakeActionsTokenPermissions(perm.AccessModeWrite) + default: + return ActionsTokenPermissions{} + } +} + +// GetMaxTokenPermissions returns the maximum allowed permissions +func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions { + if cfg.MaxTokenPermissions != nil { + return *cfg.MaxTokenPermissions + } + // Default max is write for everything + return MakeActionsTokenPermissions(perm.AccessModeWrite) +} + +// ClampPermissions ensures that the given permissions don't exceed the maximum +func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions { + maxPerms := cfg.GetMaxTokenPermissions() + return ClampActionsTokenPermissions(perms, maxPerms) +} + +// FromDB fills up a ActionsConfig from serialized format. +func (cfg *ActionsConfig) FromDB(bs []byte) error { + _ = json.UnmarshalHandleDoubleEncode(bs, &cfg) + cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode) + return nil +} + +// ToDB exports a ActionsConfig to a serialized format. +func (cfg *ActionsConfig) ToDB() ([]byte, error) { + return json.Marshal(cfg) +} diff --git a/models/repo/repo_unit_test.go b/models/repo/repo_unit_test.go index 56dda5672d4..08f9ac2cd4e 100644 --- a/models/repo/repo_unit_test.go +++ b/models/repo/repo_unit_test.go @@ -4,8 +4,12 @@ package repo import ( + "strings" "testing" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" + "github.com/stretchr/testify/assert" ) @@ -26,5 +30,75 @@ func TestActionsConfig(t *testing.T) { cfg.DisableWorkflow("test1.yaml") cfg.DisableWorkflow("test2.yaml") cfg.DisableWorkflow("test3.yaml") - assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", cfg.ToString()) + assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", strings.Join(cfg.DisabledWorkflows, ",")) +} + +func TestActionsConfigTokenPermissions(t *testing.T) { + t.Run("Default Permission Mode", func(t *testing.T) { + cfg := &ActionsConfig{TokenPermissionMode: "invalid-value"} + _ = cfg.FromDB(nil) + assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.TokenPermissionMode) + assert.Equal(t, perm.AccessModeWrite, cfg.GetDefaultTokenPermissions().UnitAccessModes[unit.TypeCode]) + }) + + t.Run("Explicit Permission Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModeRestricted, + } + assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.TokenPermissionMode) + }) + + t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModePermissive, + } + defaultPerms := cfg.GetDefaultTokenPermissions() + perms := cfg.ClampPermissions(defaultPerms) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages]) + }) + + t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) { + cfg := &ActionsConfig{ + TokenPermissionMode: ActionsTokenPermissionModeRestricted, + } + defaultPerms := cfg.GetDefaultTokenPermissions() + perms := cfg.ClampPermissions(defaultPerms) + assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeNone, perms.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypePackages]) + }) + + t.Run("Clamp Permissions", func(t *testing.T) { + cfg := &ActionsConfig{ + MaxTokenPermissions: &ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeRead, + unit.TypeIssues: perm.AccessModeWrite, + unit.TypePullRequests: perm.AccessModeRead, + unit.TypePackages: perm.AccessModeRead, + unit.TypeActions: perm.AccessModeNone, + unit.TypeWiki: perm.AccessModeWrite, + }, + }, + } + input := ActionsTokenPermissions{ + UnitAccessModes: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeWrite, // Should be clamped to Read + unit.TypeIssues: perm.AccessModeWrite, // Should stay Write + unit.TypePullRequests: perm.AccessModeWrite, // Should be clamped to Read + unit.TypePackages: perm.AccessModeWrite, // Should be clamped to Read + unit.TypeActions: perm.AccessModeRead, // Should be clamped to None + unit.TypeWiki: perm.AccessModeRead, // Should stay Read + }, + } + clamped := cfg.ClampPermissions(input) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, clamped.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki]) + }) } diff --git a/models/user/badge.go b/models/user/badge.go index e475ceb7489..fbba8659261 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -5,9 +5,12 @@ package user import ( "context" - "fmt" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" + "xorm.io/xorm/schemas" ) // Badge represents a user badge @@ -22,7 +25,16 @@ type Badge struct { type UserBadge struct { //nolint:revive // export stutter ID int64 `xorm:"pk autoincr"` BadgeID int64 - UserID int64 `xorm:"INDEX"` + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices } func init() { @@ -42,32 +54,85 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { return badges, count, err } -// CreateBadge creates a new badge. -func CreateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Insert(badge) - return err +// GetBadgeUsersOptions contains options for getting users with a specific badge +type GetBadgeUsersOptions struct { + db.ListOptions + BadgeSlug string } -// GetBadge returns a badge +// GetBadgeUsers returns the users that have a specific badge with pagination support. +func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("INNER", "user_badge", "`user_badge`.user_id=user.id"). + Join("INNER", "badge", "`user_badge`.badge_id=badge.id"). + Where("badge.slug=?", opts.BadgeSlug) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, opts) + } + + users := make([]*User, 0, opts.PageSize) + count, err := sess.FindAndCount(&users) + return users, count, err +} + +// CreateBadge creates a new badge. +func CreateBadge(ctx context.Context, badge *Badge) error { + exists, err := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge)) + if err != nil { + return err + } + if exists { + return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug) + } + + if _, err := db.GetEngine(ctx).Insert(badge); err != nil { + // Handle race between existence check and insert. + exists, existErr := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge)) + if existErr == nil && exists { + return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug) + } + return err + } + return nil +} + +// GetBadge returns a specific badge func GetBadge(ctx context.Context, slug string) (*Badge, error) { badge := new(Badge) has, err := db.GetEngine(ctx).Where("slug=?", slug).Get(badge) - if !has { + if err != nil { return nil, err } - return badge, err + if !has { + return nil, util.NewNotExistErrorf("badge does not exist [slug: %s]", slug) + } + return badge, nil } // UpdateBadge updates a badge based on its slug. func UpdateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Update(badge) + _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Cols("description", "image_url").Update(badge) return err } -// DeleteBadge deletes a badge. +// DeleteBadge deletes a badge and all associated user_badge entries. func DeleteBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge) - return err + return db.WithTx(ctx, func(ctx context.Context) error { + // First delete all user_badge entries for this badge + if _, err := db.GetEngine(ctx). + Where("badge_id = ?", badge.ID). + Delete(&UserBadge{}); err != nil { + return err + } + + // Then delete the badge itself + if _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge); err != nil { + return err + } + return nil + }) } // AddUserBadge adds a badge to a user. @@ -84,12 +149,25 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error { if err != nil { return err } else if !has { - return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug) + return util.NewNotExistErrorf("badge does not exist [slug: %s]", badge.Slug) } + + exists, err := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge)) + if err != nil { + return err + } + if exists { + return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID) + } + if err := db.Insert(ctx, &UserBadge{ BadgeID: badge.ID, UserID: u.ID, }); err != nil { + exists, existErr := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge)) + if existErr == nil && exists { + return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID) + } return err } } @@ -102,16 +180,33 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error { return RemoveUserBadges(ctx, u, []*Badge{badge}) } -// RemoveUserBadges removes badges from a user. +// RemoveUserBadges removes specific badges from a user. func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error { return db.WithTx(ctx, func(ctx context.Context) error { + if len(badges) == 0 { + return nil + } + + badgeSlugs := make([]string, 0, len(badges)) for _, badge := range badges { - if _, err := db.GetEngine(ctx). - Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). - Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug). - Delete(&UserBadge{}); err != nil { - return err - } + badgeSlugs = append(badgeSlugs, badge.Slug) + } + var userBadges []UserBadge + if err := db.GetEngine(ctx).Table("user_badge"). + Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). + Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs). + Find(&userBadges); err != nil { + return err + } + userBadgeIDs := make([]int64, 0, len(userBadges)) + for _, ub := range userBadges { + userBadgeIDs = append(userBadgeIDs, ub.ID) + } + if len(userBadgeIDs) == 0 { + return nil + } + if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil { + return err } return nil }) @@ -122,3 +217,57 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error { _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) return err } + +// SearchBadgeOptions represents the options when finding badges +type SearchBadgeOptions struct { + db.ListOptions + + Keyword string + Slug string + ID int64 + OrderBy db.SearchOrderBy +} + +func (opts *SearchBadgeOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Keyword != "" { + keywordCond := builder.Or( + db.BuildCaseInsensitiveLike("badge.slug", opts.Keyword), + db.BuildCaseInsensitiveLike("badge.description", opts.Keyword), + ) + cond = cond.And(keywordCond) + } + + if opts.ID > 0 { + cond = cond.And(builder.Eq{"badge.id": opts.ID}) + } + + if len(opts.Slug) > 0 { + cond = cond.And(builder.Eq{"badge.slug": opts.Slug}) + } + + return cond +} + +func (opts *SearchBadgeOptions) ToOrders() string { + return opts.OrderBy.String() +} + +// SearchBadges returns badges based on the provided SearchBadgeOptions options +func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) { + return db.FindAndCount[Badge](ctx, opts) +} + +// GetBadgeByID returns a specific badge by ID +func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) { + badge := new(Badge) + has, err := db.GetEngine(ctx).ID(id).Get(badge) + if err != nil { + return nil, err + } + if !has { + return nil, util.NewNotExistErrorf("badge does not exist [id: %d]", id) + } + return badge, nil +} diff --git a/models/user/badge_test.go b/models/user/badge_test.go new file mode 100644 index 00000000000..04979a9cafe --- /dev/null +++ b/models/user/badge_test.go @@ -0,0 +1,185 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" +) + +func TestBadge(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("GetBadgeNotExist", testGetBadgeNotExist) + t.Run("CreateBadgeAlreadyExists", testCreateBadgeAlreadyExists) + t.Run("GetBadgeUsers", testGetBadgeUsers) + t.Run("AddAndRemoveUserBadges", testAddAndRemoveUserBadges) + t.Run("SearchBadgesOrderingAndKeyword", testSearchBadgesOrderingAndKeyword) +} + +func testGetBadgeNotExist(t *testing.T) { + badge, err := user_model.GetBadge(t.Context(), "does-not-exist") + assert.Nil(t, badge) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) +} + +func testCreateBadgeAlreadyExists(t *testing.T) { + badge := &user_model.Badge{ + Slug: "duplicate-badge-slug", + Description: "First", + } + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + + err := user_model.CreateBadge(t.Context(), &user_model.Badge{ + Slug: "duplicate-badge-slug", + Description: "Second", + }) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrAlreadyExist) +} + +func testGetBadgeUsers(t *testing.T) { + // Create a test badge + badge := &user_model.Badge{ + Slug: "test-badge-users", + Description: "Test Badge", + ImageURL: "test.png", + } + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + + // Create test users and assign badges + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge)) + assert.NoError(t, user_model.AddUserBadge(t.Context(), user2, badge)) + defer func() { + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge)) + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user2, badge)) + }() + + // Test getting users with pagination + opts := &user_model.GetBadgeUsersOptions{ + BadgeSlug: badge.Slug, + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 1, + }, + } + + users, count, err := user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test second page + opts.Page = 2 + users, count, err = user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test with non-existent badge + opts.BadgeSlug = "non-existent" + users, count, err = user_model.GetBadgeUsers(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 0, count) + assert.Empty(t, users) +} + +func testAddAndRemoveUserBadges(t *testing.T) { + badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Add a badge to user and verify that it is returned in the list + assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge1)) + badges, count, err := user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Confirm that it is impossible to duplicate the same badge + err = user_model.AddUserBadge(t.Context(), user1, badge1) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrAlreadyExist) + + // Nothing happened to the existing badge + badges, count, err = user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Remove a badge from user and verify that it is no longer in the list + assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge1)) + _, count, err = user_model.GetUserBadges(t.Context(), user1) + assert.Equal(t, int64(0), count) + assert.NoError(t, err) + + // Removing empty or missing badge selections should be a no-op. + assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, nil)) + assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, []*user_model.Badge{{Slug: "does-not-exist"}})) +} + +func testSearchBadgesOrderingAndKeyword(t *testing.T) { + createdBadges := []*user_model.Badge{ + {Slug: "badge-sort-b", Description: "Badge Sort B"}, + {Slug: "badge-sort-c", Description: "Badge Sort C"}, + {Slug: "badge-sort-a", Description: "Badge Sort A"}, + {Slug: "badge-sort-case", Description: "MiXeDCaSeKeyword"}, + } + for _, badge := range createdBadges { + assert.NoError(t, user_model.CreateBadge(t.Context(), badge)) + } + + opts := &user_model.SearchBadgeOptions{ + ListOptions: db.ListOptions{ListAll: true}, + Keyword: "badge-sort-", + OrderBy: db.SearchOrderBy("`badge`.id ASC"), + } + + oldestFirst, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-b", "badge-sort-c", "badge-sort-a", "badge-sort-case"}, collectBadgeSlugs(oldestFirst)) + + opts.OrderBy = db.SearchOrderBy("`badge`.id DESC") + newestFirst, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-case", "badge-sort-a", "badge-sort-c", "badge-sort-b"}, collectBadgeSlugs(newestFirst)) + + opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC") + alpha, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-a", "badge-sort-b", "badge-sort-c", "badge-sort-case"}, collectBadgeSlugs(alpha)) + + opts.OrderBy = db.SearchOrderBy("`badge`.slug DESC") + reverseAlpha, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 4, count) + assert.Equal(t, []string{"badge-sort-case", "badge-sort-c", "badge-sort-b", "badge-sort-a"}, collectBadgeSlugs(reverseAlpha)) + + opts.Keyword = "mixedcasekeyword" + opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC") + caseInsensitive, count, err := user_model.SearchBadges(t.Context(), opts) + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + assert.Equal(t, []string{"badge-sort-case"}, collectBadgeSlugs(caseInsensitive)) +} + +func collectBadgeSlugs(badges []*user_model.Badge) []string { + slugs := make([]string, 0, len(badges)) + for _, badge := range badges { + slugs = append(slugs, badge.Slug) + } + return slugs +} diff --git a/models/user/setting.go b/models/user/setting.go index c65afae76c8..a16fc86e55e 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -11,10 +11,12 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/json" setting_module "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "xorm.io/builder" + "xorm.io/xorm/convert" ) // Setting is a key value store of user settings @@ -211,3 +213,44 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string return err }) } + +func GetUserSettingJSON[T any](ctx context.Context, userID int64, key string, def T) (ret T, _ error) { + ret = def + str, err := GetUserSetting(ctx, userID, key) + if err != nil { + return ret, err + } + + conv, ok := any(&ret).(convert.ConversionFrom) + if !ok { + conv, ok = any(ret).(convert.ConversionFrom) + } + if ok { + if err := conv.FromDB(util.UnsafeStringToBytes(str)); err != nil { + return ret, err + } + } else { + if str == "" { + return ret, nil + } + err = json.Unmarshal(util.UnsafeStringToBytes(str), &ret) + } + return ret, err +} + +func SetUserSettingJSON[T any](ctx context.Context, userID int64, key string, val T) (err error) { + conv, ok := any(&val).(convert.ConversionTo) + if !ok { + conv, ok = any(val).(convert.ConversionTo) + } + var bs []byte + if ok { + bs, err = conv.ToDB() + } else { + bs, err = json.Marshal(val) + } + if err != nil { + return err + } + return SetUserSetting(ctx, userID, key, util.UnsafeBytesToString(bs)) +} diff --git a/models/user/setting_options.go b/models/user/setting_options.go index acb34da3a5a..8389b0419d4 100644 --- a/models/user/setting_options.go +++ b/models/user/setting_options.go @@ -26,4 +26,6 @@ const ( UserSSHMirrorPrivPem = "ssh_mirror.priv_pem" UserSSHMirrorPubPem = "ssh_mirror.pub_pem" UserSSHMirrorFingerprint = "ssh_mirror.fingerprint" + + SettingsKeyActionsConfig = "actions.config" ) diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index d28726e8993..e8bf70ec310 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -20,7 +20,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool { func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) + u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String(), http.StatusFound) return true, nil diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 2d66a86a8b0..fc7edc36c43 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -112,21 +112,20 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt opts.ContentTypeCharset = strings.ToLower(charset) } - isSVG := sniffedType.IsSvgImage() - // serve types that can present a security risk with CSP - if isSVG { - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - } else if sniffedType.IsPDF() { - // no sandbox attribute for pdf as it breaks rendering in at least safari. this + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + + if sniffedType.IsPDF() { + // no sandbox attribute for PDF as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") } + // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE opts.Disposition = "inline" - if isSVG && !setting.UI.SVG.Enabled { + if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled { opts.Disposition = "attachment" } diff --git a/modules/packages/content_store.go b/modules/packages/content_store.go index 57974515e2c..4c61233b5e7 100644 --- a/modules/packages/content_store.go +++ b/modules/packages/content_store.go @@ -36,8 +36,8 @@ func (s *ContentStore) ShouldServeDirect() bool { return setting.Packages.Storage.ServeDirect() } -func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams url.Values) (*url.URL, error) { - return s.store.URL(KeyToRelativePath(key), filename, method, reqParams) +func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams *storage.ServeDirectOptions) (*url.URL, error) { + return s.store.ServeDirectURL(KeyToRelativePath(key), filename, method, reqParams) } // FIXME: Workaround to be removed in v1.20 diff --git a/modules/private/hook.go b/modules/private/hook.go index 215996b9b99..ce87ccd8019 100644 --- a/modules/private/hook.go +++ b/modules/private/hook.go @@ -37,7 +37,7 @@ type HookOptions struct { PushTrigger repository.PushTrigger DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user. IsWiki bool - ActionPerm int + ActionsTaskID int64 // if the pusher is an Actions user, the task ID } // SSHLogOption ssh log options diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index 32bdf3bfa25..fef85d77cbe 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -3,9 +3,11 @@ package public -import "strings" +import ( + "strings" +) -// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of detectWellKnownMimeType +// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType var wellKnownMimeTypesLower = map[string]string{ ".avif": "image/avif", ".css": "text/css; charset=utf-8", @@ -28,13 +30,13 @@ var wellKnownMimeTypesLower = map[string]string{ ".txt": "text/plain; charset=utf-8", } -// detectWellKnownMimeType will return the mime-type for a well-known file ext name +// DetectWellKnownMimeType will return the mime-type for a well-known file ext name // The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension // mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document). // If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers. // For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons. -// detectWellKnownMimeType makes the Content-Type for well-known files stable. -func detectWellKnownMimeType(ext string) string { +// DetectWellKnownMimeType makes the Content-Type for well-known files stable. +func DetectWellKnownMimeType(ext string) string { ext = strings.ToLower(ext) return wellKnownMimeTypesLower[ext] } diff --git a/modules/public/public.go b/modules/public/public.go index 3a5a76637e7..004aad5f3b1 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -51,9 +51,9 @@ func parseAcceptEncoding(val string) container.Set[string] { } // setWellKnownContentType will set the Content-Type if the file is a well-known type. -// See the comments of detectWellKnownMimeType +// See the comments of DetectWellKnownMimeType func setWellKnownContentType(w http.ResponseWriter, file string) { - mimeType := detectWellKnownMimeType(path.Ext(file)) + mimeType := DetectWellKnownMimeType(path.Ext(file)) if mimeType != "" { w.Header().Set("Content-Type", mimeType) } diff --git a/modules/repository/env.go b/modules/repository/env.go index 55a81f006e2..ed2c6fef81a 100644 --- a/modules/repository/env.go +++ b/modules/repository/env.go @@ -11,25 +11,26 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) // env keys for git hooks need const ( - EnvRepoName = "GITEA_REPO_NAME" - EnvRepoUsername = "GITEA_REPO_USER_NAME" - EnvRepoID = "GITEA_REPO_ID" - EnvRepoIsWiki = "GITEA_REPO_IS_WIKI" - EnvPusherName = "GITEA_PUSHER_NAME" - EnvPusherEmail = "GITEA_PUSHER_EMAIL" - EnvPusherID = "GITEA_PUSHER_ID" - EnvKeyID = "GITEA_KEY_ID" // public key ID - EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" - EnvPRID = "GITEA_PR_ID" - EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks - EnvPushTrigger = "GITEA_PUSH_TRIGGER" - EnvIsInternal = "GITEA_INTERNAL_PUSH" - EnvAppURL = "GITEA_ROOT_URL" - EnvActionPerm = "GITEA_ACTION_PERM" + EnvRepoName = "GITEA_REPO_NAME" + EnvRepoUsername = "GITEA_REPO_USER_NAME" + EnvRepoID = "GITEA_REPO_ID" + EnvRepoIsWiki = "GITEA_REPO_IS_WIKI" + EnvPusherName = "GITEA_PUSHER_NAME" + EnvPusherEmail = "GITEA_PUSHER_EMAIL" + EnvPusherID = "GITEA_PUSHER_ID" + EnvKeyID = "GITEA_KEY_ID" // public key ID + EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" + EnvPRID = "GITEA_PR_ID" + EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks + EnvPushTrigger = "GITEA_PUSH_TRIGGER" + EnvIsInternal = "GITEA_INTERNAL_PUSH" + EnvAppURL = "GITEA_ROOT_URL" + EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID" ) type PushTrigger string @@ -54,36 +55,39 @@ func PushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []st return FullPushingEnvironment(doer, doer, repo, repo.Name, 0, 0) } +func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository, isWiki bool) []string { + env := []string{ + EnvAppURL + "=" + setting.AppURL, + EnvRepoName + "=" + repo.Name + util.Iif(isWiki, ".wiki", ""), + EnvRepoUsername + "=" + repo.OwnerName, + EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10), + EnvRepoIsWiki + "=" + strconv.FormatBool(isWiki), + EnvPusherName + "=" + doer.Name, + EnvPusherID + "=" + strconv.FormatInt(doer.ID, 10), + } + if !doer.KeepEmailPrivate { + env = append(env, EnvPusherEmail+"="+doer.Email) + } + if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser { + env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10)) + } + return env +} + // FullPushingEnvironment returns an os environment to allow hooks to work on push func FullPushingEnvironment(author, committer *user_model.User, repo *repo_model.Repository, repoName string, prID, prIndex int64) []string { - isWiki := "false" - if strings.HasSuffix(repoName, ".wiki") { - isWiki = "true" - } - + isWiki := strings.HasSuffix(repoName, ".wiki") authorSig := author.NewGitSig() committerSig := committer.NewGitSig() - environ := append(os.Environ(), "GIT_AUTHOR_NAME="+authorSig.Name, "GIT_AUTHOR_EMAIL="+authorSig.Email, "GIT_COMMITTER_NAME="+committerSig.Name, "GIT_COMMITTER_EMAIL="+committerSig.Email, - EnvRepoName+"="+repoName, - EnvRepoUsername+"="+repo.OwnerName, - EnvRepoIsWiki+"="+isWiki, - EnvPusherName+"="+committer.Name, - EnvPusherID+"="+strconv.FormatInt(committer.ID, 10), - EnvRepoID+"="+strconv.FormatInt(repo.ID, 10), EnvPRID+"="+strconv.FormatInt(prID, 10), EnvPRIndex+"="+strconv.FormatInt(prIndex, 10), - EnvAppURL+"="+setting.AppURL, "SSH_ORIGINAL_COMMAND=gitea-internal", ) - - if !committer.KeepEmailPrivate { - environ = append(environ, EnvPusherEmail+"="+committer.Email) - } - + environ = append(environ, DoerPushingEnvironment(committer, repo, isWiki)...) return environ } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 662e03598b5..f4e45d4702f 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -89,6 +89,7 @@ var ( TestConflictingPatchesWithGitApply bool RetargetChildrenOnMerge bool DelayCheckForInactiveDays int + DefaultDeleteBranchAfterMerge bool } `ini:"repository.pull-request"` // Issue Setting @@ -213,6 +214,7 @@ var ( TestConflictingPatchesWithGitApply bool RetargetChildrenOnMerge bool DelayCheckForInactiveDays int + DefaultDeleteBranchAfterMerge bool }{ WorkInProgressPrefixes: []string{"WIP:", "[WIP]"}, // Same as GitHub. See diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go index e7297cec77a..a273a7770d0 100644 --- a/modules/storage/azureblob.go +++ b/modules/storage/azureblob.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "path" @@ -246,16 +247,53 @@ func (a *AzureBlobStorage) Delete(path string) error { return convertAzureBlobErr(err) } -// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { - blobClient := a.getBlobClient(path) +func (a *AzureBlobStorage) getSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) { + urlParts, err := blob.ParseURL(b.URL()) + if err != nil { + return "", err + } - // TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage - startTime := time.Now() - u, err := blobClient.GetSASURL(sas.BlobPermissions{ - Read: true, - }, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{ - StartTime: &startTime, + var t time.Time + if urlParts.Snapshot == "" { + t = time.Time{} + } else { + t, err = time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot) + if err != nil { + return "", err + } + } + + template.ContainerName = urlParts.ContainerName + template.BlobName = urlParts.BlobName + template.SnapshotTime = t + template.Version = sas.Version + + qps, err := template.SignWithSharedKey(a.credential) + if err != nil { + return "", err + } + + endpoint := b.URL() + "?" + qps.Encode() + + return endpoint, nil +} + +func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqParams *ServeDirectOptions) (*url.URL, error) { + blobClient := a.getBlobClient(storePath) + + startTime := time.Now().UTC() + + param := prepareServeDirectOptions(reqParams, name) + + u, err := a.getSasURL(blobClient, sas.BlobSignatureValues{ + Permissions: (&sas.BlobPermissions{ + Read: method == http.MethodGet || method == http.MethodHead, + Write: method == http.MethodPut, + }).String(), + StartTime: startTime, + ExpiryTime: startTime.Add(5 * time.Minute), + ContentDisposition: param.ContentDisposition, + ContentType: param.ContentType, }) if err != nil { return nil, convertAzureBlobErr(err) diff --git a/modules/storage/azureblob_test.go b/modules/storage/azureblob_test.go index b3791b49164..b5d5d0fecc0 100644 --- a/modules/storage/azureblob_test.go +++ b/modules/storage/azureblob_test.go @@ -14,12 +14,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAzureBlobStorageIterator(t *testing.T) { +func TestAzureBlobStorage(t *testing.T) { if os.Getenv("CI") == "" { t.Skip("azureBlobStorage not present outside of CI") return } - testStorageIterator(t, setting.AzureBlobStorageType, &setting.Storage{ + storageType := setting.AzureBlobStorageType + config := &setting.Storage{ AzureBlobConfig: setting.AzureBlobStorageConfig{ // https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url Endpoint: "http://devstoreaccount1.azurite.local:10000", @@ -28,7 +29,25 @@ func TestAzureBlobStorageIterator(t *testing.T) { AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", Container: "test", }, - }) + } + table := []struct { + name string + test func(t *testing.T, typStr Type, cfg *setting.Storage) + }{ + { + name: "iterator", + test: testStorageIterator, + }, + { + name: "testBlobStorageURLContentTypeAndDisposition", + test: testBlobStorageURLContentTypeAndDisposition, + }, + } + for _, entry := range table { + t.Run(entry.name, func(t *testing.T) { + entry.test(t, storageType, config) + }) + } } func TestAzureBlobStoragePath(t *testing.T) { diff --git a/modules/storage/helper.go b/modules/storage/helper.go index f6c3d5eebbc..30d96a33add 100644 --- a/modules/storage/helper.go +++ b/modules/storage/helper.go @@ -30,7 +30,7 @@ func (s discardStorage) Delete(_ string) error { return fmt.Errorf("%s", s) } -func (s discardStorage) URL(_, _, _ string, _ url.Values) (*url.URL, error) { +func (s discardStorage) ServeDirectURL(_, _, _ string, _ *ServeDirectOptions) (*url.URL, error) { return nil, fmt.Errorf("%s", s) } diff --git a/modules/storage/helper_test.go b/modules/storage/helper_test.go index 3cba1e13c01..ae64c8aca49 100644 --- a/modules/storage/helper_test.go +++ b/modules/storage/helper_test.go @@ -37,7 +37,7 @@ func Test_discardStorage(t *testing.T) { assert.Error(t, err, string(tt)) } { - got, err := tt.URL("path", "name", "GET", nil) + got, err := tt.ServeDirectURL("path", "name", "GET", nil) assert.Nil(t, got) assert.Errorf(t, err, string(tt)) } diff --git a/modules/storage/local.go b/modules/storage/local.go index 5ea6f055ce7..04e3e05f950 100644 --- a/modules/storage/local.go +++ b/modules/storage/local.go @@ -133,8 +133,7 @@ func (l *LocalStorage) Delete(path string) error { return err } -// URL gets the redirect URL to a file -func (l *LocalStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) { +func (l *LocalStorage) ServeDirectURL(path, name, _ string, reqParams *ServeDirectOptions) (*url.URL, error) { return nil, ErrURLNotSupported } diff --git a/modules/storage/minio.go b/modules/storage/minio.go index 6993ac2d922..1355280f367 100644 --- a/modules/storage/minio.go +++ b/modules/storage/minio.go @@ -278,37 +278,16 @@ func (m *MinioStorage) Delete(path string) error { return convertMinioErr(err) } -// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes. -func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams url.Values) (*url.URL, error) { - // copy serveDirectReqParams - reqParams, err := url.ParseQuery(serveDirectReqParams.Encode()) - if err != nil { - return nil, err - } +func (m *MinioStorage) ServeDirectURL(storePath, name, method string, opt *ServeDirectOptions) (*url.URL, error) { + reqParams := url.Values{} - // Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head. - // So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI. - // Detect content type by extension name, only support the well-known safe types for inline rendering. - // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future - ext := path.Ext(name) - inlineExtMimeTypes := map[string]string{ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - // ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy - // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline - ".pdf": "application/pdf", - - // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType" + param := prepareServeDirectOptions(opt, name) + // minio does not ignore empty params + if param.ContentType != "" { + reqParams.Set("response-content-type", param.ContentType) } - if mimeType, ok := inlineExtMimeTypes[ext]; ok { - reqParams.Set("response-content-type", mimeType) - reqParams.Set("response-content-disposition", "inline") - } else { - reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name))) + if param.ContentDisposition != "" { + reqParams.Set("response-content-disposition", param.ContentDisposition) } expires := 5 * time.Minute @@ -323,6 +302,7 @@ func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams // IterateObjects iterates across the objects in the miniostorage func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error { opts := minio.GetObjectOptions{} + // FIXME: this loop is not right and causes resource leaking, see the comment of ListObjects for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{ Prefix: m.buildMinioDirPrefix(dirName), Recursive: true, diff --git a/modules/storage/minio_test.go b/modules/storage/minio_test.go index 2726d765ddf..5c15ee1ed6d 100644 --- a/modules/storage/minio_test.go +++ b/modules/storage/minio_test.go @@ -16,12 +16,13 @@ import ( "github.com/stretchr/testify/assert" ) -func TestMinioStorageIterator(t *testing.T) { +func TestMinioStorage(t *testing.T) { if os.Getenv("CI") == "" { t.Skip("minioStorage not present outside of CI") return } - testStorageIterator(t, setting.MinioStorageType, &setting.Storage{ + storageType := setting.MinioStorageType + config := &setting.Storage{ MinioConfig: setting.MinioStorageConfig{ Endpoint: "minio:9000", AccessKeyID: "123456", @@ -29,7 +30,25 @@ func TestMinioStorageIterator(t *testing.T) { Bucket: "gitea", Location: "us-east-1", }, - }) + } + table := []struct { + name string + test func(t *testing.T, typStr Type, cfg *setting.Storage) + }{ + { + name: "iterator", + test: testStorageIterator, + }, + { + name: "testBlobStorageURLContentTypeAndDisposition", + test: testBlobStorageURLContentTypeAndDisposition, + }, + } + for _, entry := range table { + t.Run(entry.name, func(t *testing.T) { + entry.test(t, storageType, config) + }) + } } func TestMinioStoragePath(t *testing.T) { diff --git a/modules/storage/storage.go b/modules/storage/storage.go index 74d0cd47c87..2491c77a3e0 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -10,8 +10,10 @@ import ( "io" "net/url" "os" + "path" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" ) @@ -56,6 +58,38 @@ type Object interface { Stat() (os.FileInfo, error) } +// ServeDirectOptions customizes HTTP headers for a generated signed URL. +type ServeDirectOptions struct { + // Overrides the automatically detected MIME type. + ContentType string + // Overrides the default Content-Disposition header, which is `inline; filename="name"`. + ContentDisposition string +} + +// Safe defaults are applied only when not explicitly overridden by the caller. +func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret ServeDirectOptions) { + // Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head. + // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI. + // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future + + if optsOptional != nil { + ret = *optsOptional + } + + // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE + if ret.ContentType == "" { + ext := path.Ext(name) + ret.ContentType = public.DetectWellKnownMimeType(ext) + } + if ret.ContentDisposition == "" { + // When using ServeDirect, the URL is from the object storage's web server, + // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly. + // If a browser doesn't support the content type to be displayed inline, browser will download with the filename. + ret.ContentDisposition = fmt.Sprintf(`inline; filename="%s"`, quoteEscaper.Replace(name)) + } + return ret +} + // ObjectStorage represents an object storage to handle a bucket and files type ObjectStorage interface { Open(path string) (Object, error) @@ -67,7 +101,15 @@ type ObjectStorage interface { Stat(path string) (os.FileInfo, error) Delete(path string) error - URL(path, name, method string, reqParams url.Values) (*url.URL, error) + + // ServeDirectURL generates a "serve-direct" URL for the specified blob storage file, + // end user (browser) will use this URL to access the file directly from the object storage, bypassing Gitea server. + // Usually the link is time-limited (a few minutes) and contains a signature to ensure security. + // The generated URL must NOT use the same origin as Gitea server, otherwise it will cause security issues. + // * method defines which HTTP method is permitted for certain storage providers (e.g., MinIO). + // * opt allows customizing the Content-Type and Content-Disposition headers. + // TODO: need to merge "ServeDirect()" check into this function, avoid duplicate code and potential inconsistency. + ServeDirectURL(path, name, method string, opt *ServeDirectOptions) (*url.URL, error) // IterateObjects calls the iterator function for each object in the storage with the given path as prefix // The "fullPath" argument in callback is the full path in this storage. @@ -136,7 +178,7 @@ var ( // Actions represents actions storage Actions ObjectStorage = uninitializedStorage - // Actions Artifacts represents actions artifacts storage + // ActionsArtifacts Artifacts represents actions artifacts storage ActionsArtifacts ObjectStorage = uninitializedStorage ) diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go index 08f274e74b4..4156723c364 100644 --- a/modules/storage/storage_test.go +++ b/modules/storage/storage_test.go @@ -4,12 +4,14 @@ package storage import ( + "net/http" "strings" "testing" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) { @@ -50,3 +52,55 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) { assert.Len(t, expected, count) } } + +func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) { + u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams) + require.NoError(t, err) + resp, err := http.Get(u.String()) + require.NoError(t, err) + defer resp.Body.Close() + if expected.ContentType != "" { + assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type")) + } + if expected.ContentDisposition != "" { + assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition")) + } +} + +func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg *setting.Storage) { + s, err := NewStorage(typStr, cfg) + assert.NoError(t, err) + + data := "Q2xTckt6Y1hDOWh0" // arbitrary test content; specific value is irrelevant to this test + testfilename := "test.txt" // arbitrary file name; specific value is irrelevant to this test + _, err = s.Save(testfilename, strings.NewReader(data), int64(len(data))) + assert.NoError(t, err) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ + ContentType: "text/plain; charset=utf-8", + ContentDisposition: `inline; filename="test.txt"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{ + ContentType: "application/pdf", + ContentDisposition: `inline; filename="test.pdf"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + ContentDisposition: `inline; filename="test.wasm"`, + }, nil) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{ + ContentDisposition: `inline; filename="test.wasm"`, + }, &ServeDirectOptions{}) + + testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{ + ContentType: "application/octet-stream", + ContentDisposition: `inline; filename="test.xml"`, + }, &ServeDirectOptions{ + ContentType: "application/octet-stream", + ContentDisposition: `inline; filename="test.xml"`, + }) + + assert.NoError(t, s.Delete(testfilename)) +} diff --git a/modules/util/util.go b/modules/util/util.go index d7702439d64..7d1343f20d6 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "fmt" "math/big" + "slices" "strconv" "strings" @@ -240,6 +241,20 @@ func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) { return ret } +type EnumConst[T comparable] interface { + EnumValues() []T +} + +// EnumValue returns the value if it's in the enum const's values, +// otherwise returns the first item of enums as default value. +func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) { + enums := val.EnumValues() + if slices.Contains(enums, val.(T)) { + return val.(T), true + } + return enums[0], false +} + func ReserveLineBreakForTextarea(input string) string { // Since the content is from a form which is a textarea, the line endings are \r\n. // It's a standard behavior of HTML. diff --git a/modules/validation/binding.go b/modules/validation/binding.go index 3f40e5ec975..86364e11735 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -27,6 +27,8 @@ const ( ErrUsername = "UsernameError" // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" + // ErrInvalidBadgeSlug is returned when a badge slug is invalid + ErrInvalidBadgeSlug = "InvalidBadgeSlug" ) // AddBindingRules adds additional binding rules @@ -40,6 +42,7 @@ func AddBindingRules() { addGlobOrRegexPatternRule() addUsernamePatternRule() addValidGroupTeamMapRule() + addSlugPatternRule() } func addGitRefNameBindingRule() { @@ -123,6 +126,22 @@ func addValidSiteURLBindingRule() { }) } +func addSlugPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "BadgeSlug" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if !IsValidBadgeSlug(str) { + errs.Add([]string{name}, ErrInvalidBadgeSlug, "invalid badge slug") + return false, errs + } + return true, errs + }, + }) +} + func addGlobPatternRule() { binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 051330e4397..ee05de74bdf 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -16,16 +16,20 @@ import ( ) type globalVarsStruct struct { - externalTrackerRegex *regexp.Regexp - validUsernamePattern *regexp.Regexp - invalidUsernamePattern *regexp.Regexp + externalTrackerRegex *regexp.Regexp + validUsernamePattern *regexp.Regexp + invalidUsernamePattern *regexp.Regexp + validBadgeSlugPattern *regexp.Regexp + invalidBadgeSlugPattern *regexp.Regexp } var globalVars = sync.OnceValue(func() *globalVarsStruct { return &globalVarsStruct{ - externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`), - validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`), - invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars + externalTrackerRegex: regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`), + validUsernamePattern: regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`), + invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars + validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`), + invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), } }) @@ -131,3 +135,8 @@ func IsValidUsername(name string) bool { vars := globalVars() return vars.validUsernamePattern.MatchString(name) && !vars.invalidUsernamePattern.MatchString(name) } + +func IsValidBadgeSlug(slug string) bool { + vars := globalVars() + return vars.validBadgeSlugPattern.MatchString(slug) && !vars.invalidBadgeSlugPattern.MatchString(slug) +} diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go index 6a982965f68..75f73b97fca 100644 --- a/modules/validation/helpers_test.go +++ b/modules/validation/helpers_test.go @@ -186,3 +186,24 @@ func TestIsValidUsername(t *testing.T) { }) } } + +func TestIsValidBadgeSlug(t *testing.T) { + tests := []struct { + arg string + want bool + }{ + {arg: "badge-1", want: true}, + {arg: "badge.slug", want: true}, + {arg: "new", want: true}, + {arg: "Badge_1", want: true}, + {arg: "a..b", want: false}, + {arg: "a/b", want: false}, + {arg: "Awesome!", want: false}, + {arg: "Emoji 💯", want: false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + assert.Equalf(t, tt.want, IsValidBadgeSlug(tt.arg), "IsValidBadgeSlug(%v)", tt.arg) + }) + } +} diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index ee4eca976e3..05047ad3bdb 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -138,6 +138,8 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo data["ErrorMsg"] = trName + l.TrString("form.username_error") case validation.ErrInvalidGroupTeamMap: data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message) + case validation.ErrInvalidBadgeSlug: + data["ErrorMsg"] = trName + l.TrString("form.invalid_slug_error") default: msg := errs[0].Classification if msg != "" && errs[0].Message != "" { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index bac51f5096b..d0dfae12794 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -81,6 +81,7 @@ "retry": "Retry", "rerun": "Re-run", "rerun_all": "Re-run all jobs", + "rerun_failed": "Re-run failed jobs", "save": "Save", "add": "Add", "add_all": "Add All", @@ -168,6 +169,7 @@ "search.exact_tooltip": "Include only results that match the exact search term", "search.repo_kind": "Search repos…", "search.user_kind": "Search users…", + "search.badge_kind": "Search badges…", "search.org_kind": "Search orgs…", "search.team_kind": "Search teams…", "search.code_kind": "Search code…", @@ -542,6 +544,7 @@ "form.glob_pattern_error": " glob pattern is invalid: %s.", "form.regex_pattern_error": " regex pattern is invalid: %s.", "form.username_error": " can only contain alphanumeric characters ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric characters, and consecutive non-alphanumeric characters are also forbidden.", + "form.invalid_slug_error": " is invalid.", "form.invalid_group_team_map_error": " mapping is invalid: %s", "form.unknown_error": "Unknown error:", "form.captcha_incorrect": "The CAPTCHA code is incorrect.", @@ -645,6 +648,7 @@ "user.block.note.edit": "Edit note", "user.block.list": "Blocked users", "user.block.list.none": "You have not blocked any users.", + "settings.general": "General", "settings.profile": "Profile", "settings.account": "Account", "settings.appearance": "Appearance", @@ -2869,6 +2873,30 @@ "admin.hooks": "Webhooks", "admin.integrations": "Integrations", "admin.authentication": "Authentication Sources", + "admin.badges": "Badges", + "admin.badges.badges_manage_panel": "Badge Management", + "admin.badges.details": "Badge Details", + "admin.badges.new_badge": "Create New Badge", + "admin.badges.slug": "Slug", + "admin.badges.slug_been_taken": "The slug is already taken.", + "admin.badges.description": "Description", + "admin.badges.image_url": "Image URL", + "admin.badges.new_success": "The badge \"%s\" has been created.", + "admin.badges.update_success": "The badge has been updated.", + "admin.badges.deletion_success": "The badge has been deleted.", + "admin.badges.edit_badge": "Edit Badge", + "admin.badges.update_badge": "Update Badge", + "admin.badges.delete_badge": "Delete Badge", + "admin.badges.delete_badge_desc": "Are you sure you want to permanently delete this badge?", + "admin.badges.users_with_badge": "Users with badge: %s", + "admin.badges.not_found": "Badge not found.", + "admin.badges.user_already_has": "User already has this badge.", + "admin.badges.user_add_success": "Badge assigned to user successfully.", + "admin.badges.user_remove_success": "Badge removed from user successfully.", + "admin.badges.manage_users": "Manage Users", + "admin.badges.add_user": "Add User", + "admin.badges.remove_user": "Remove User", + "admin.badges.delete_user_desc": "Are you sure you want to remove this user from the badge?", "admin.emails": "User Email Addresses", "admin.config": "Configuration", "admin.config_summary": "Summary", @@ -3720,6 +3748,10 @@ "actions.runs.not_done": "This workflow run is not done.", "actions.runs.view_workflow_file": "View workflow file", "actions.runs.workflow_graph": "Workflow Graph", + "actions.runs.summary": "Summary", + "actions.runs.all_jobs": "All jobs", + "actions.runs.triggered_via": "Triggered via %s", + "actions.runs.total_duration": "Total duration:", "actions.workflow.disable": "Disable Workflow", "actions.workflow.disable_success": "Workflow '%s' disabled successfully.", "actions.workflow.enable": "Enable Workflow", @@ -3769,5 +3801,24 @@ "git.filemode.normal_file": "Regular", "git.filemode.executable_file": "Executable", "git.filemode.symbolic_link": "Symlink", - "git.filemode.submodule": "Submodule" + "git.filemode.submodule": "Submodule", + "org.repos.none": "No repositories.", + "actions.general.permissions": "Actions Token Permissions", + "actions.general.token_permissions.mode": "Default Token Permissions", + "actions.general.token_permissions.mode.desc": "An Actions job will use the default permissions if it doesn't declare its permissions in the workflow file.", + "actions.general.token_permissions.mode.permissive": "Permissive", + "actions.general.token_permissions.mode.permissive.desc": "Read and write permissions for the job's repository.", + "actions.general.token_permissions.mode.restricted": "Restricted", + "actions.general.token_permissions.mode.restricted.desc": "Read-only permissions for contents units (code, releases) of the job's repository.", + "actions.general.token_permissions.override_owner": "Override owner-level configuration", + "actions.general.token_permissions.override_owner_desc": "If enabled, this repository will use its own Actions configuration instead of following the owner-level (user or organization) configuration.", + "actions.general.token_permissions.maximum": "Maximum Token Permissions", + "actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.", + "actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.", + "actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions", + "actions.general.cross_repo": "Cross-Repository Access", + "actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.", + "actions.general.cross_repo_selected": "Selected repositories", + "actions.general.cross_repo_target_repos": "Target Repositories", + "actions.general.cross_repo_add": "Add Target Repository" } diff --git a/package.json b/package.json index 11dc64ee6a3..4dd3f14e069 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "4.7.1", - "@eslint/json": "0.14.0", + "@eslint/json": "1.1.0", "@playwright/test": "1.58.2", "@stylistic/eslint-plugin": "5.10.0", "@stylistic/stylelint-plugin": "5.0.1", @@ -82,17 +82,18 @@ "@types/swagger-ui-dist": "3.30.6", "@types/throttle-debounce": "5.0.2", "@types/toastify-js": "1.12.4", - "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/parser": "8.57.1", "@vitejs/plugin-vue": "6.0.4", - "@vitest/eslint-plugin": "1.6.9", - "eslint": "9.39.2", + "@vitest/eslint-plugin": "1.6.12", + "eslint": "10.0.3", "eslint-import-resolver-typescript": "4.4.4", - "eslint-plugin-array-func": "5.1.0", + "eslint-plugin-array-func": "5.1.1", "eslint-plugin-github": "6.0.0", - "eslint-plugin-import-x": "4.16.1", - "eslint-plugin-playwright": "2.9.0", - "eslint-plugin-regexp": "3.0.0", - "eslint-plugin-sonarjs": "4.0.1", + "eslint-plugin-de-morgan": "2.1.1", + "eslint-plugin-import-x": "4.16.2", + "eslint-plugin-playwright": "2.10.1", + "eslint-plugin-regexp": "3.1.0", + "eslint-plugin-sonarjs": "4.0.2", "eslint-plugin-unicorn": "63.0.0", "eslint-plugin-vue": "10.8.0", "eslint-plugin-vue-scoped-css": "3.0.0", @@ -112,13 +113,18 @@ "stylelint-value-no-unknown-custom-properties": "6.1.1", "svgo": "4.0.1", "typescript": "5.9.3", - "typescript-eslint": "8.56.1", + "typescript-eslint": "8.57.1", "updates": "17.8.3", "vite-string-plugin": "2.0.1", "vitest": "4.0.18", "vue-tsc": "3.2.5" }, "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "eslint-plugin-github>eslint": ">=9" + } + }, "overrides": { "array-includes": "npm:@nolyfill/array-includes@^1", "array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@^1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 553364a5bc1..5bb595bf0ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,16 +203,16 @@ importers: devDependencies: '@eslint-community/eslint-plugin-eslint-comments': specifier: 4.7.1 - version: 4.7.1(eslint@9.39.2(jiti@2.6.1)) + version: 4.7.1(eslint@10.0.3(jiti@2.6.1)) '@eslint/json': - specifier: 0.14.0 - version: 0.14.0 + specifier: 1.1.0 + version: 1.1.0 '@playwright/test': specifier: 1.58.2 version: 1.58.2 '@stylistic/eslint-plugin': specifier: 5.10.0 - version: 5.10.0(eslint@9.39.2(jiti@2.6.1)) + version: 5.10.0(eslint@10.0.3(jiti@2.6.1)) '@stylistic/stylelint-plugin': specifier: 5.0.1 version: 5.0.1(stylelint@17.4.0(typescript@5.9.3)) @@ -250,50 +250,53 @@ importers: specifier: 1.12.4 version: 1.12.4 '@typescript-eslint/parser': - specifier: 8.56.1 - version: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.57.1 + version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.4 version: 6.0.4(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) '@vitest/eslint-plugin': - specifier: 1.6.9 - version: 1.6.9(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + specifier: 1.6.12 + version: 1.6.12(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 10.0.3 + version: 10.0.3(jiti@2.6.1) eslint-import-resolver-typescript: specifier: 4.4.4 - version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-array-func: - specifier: 5.1.0 - version: 5.1.0(eslint@9.39.2(jiti@2.6.1)) + specifier: 5.1.1 + version: 5.1.1(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-de-morgan: + specifier: 2.1.1 + version: 2.1.1(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-github: specifier: 6.0.0 - version: 6.0.0(@types/eslint@9.6.1)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + version: 6.0.0(@types/eslint@9.6.1)(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-import-x: - specifier: 4.16.1 - version: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + specifier: 4.16.2 + version: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-playwright: - specifier: 2.9.0 - version: 2.9.0(eslint@9.39.2(jiti@2.6.1)) + specifier: 2.10.1 + version: 2.10.1(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-regexp: - specifier: 3.0.0 - version: 3.0.0(eslint@9.39.2(jiti@2.6.1)) + specifier: 3.1.0 + version: 3.1.0(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-sonarjs: - specifier: 4.0.1 - version: 4.0.1(eslint@9.39.2(jiti@2.6.1)) + specifier: 4.0.2 + version: 4.0.2(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-unicorn: specifier: 63.0.0 - version: 63.0.0(eslint@9.39.2(jiti@2.6.1)) + version: 63.0.0(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.8.0 - version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1))) + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.0.3(jiti@2.6.1)))(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) eslint-plugin-vue-scoped-css: specifier: 3.0.0 - version: 3.0.0(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1))) + version: 3.0.0(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) eslint-plugin-wc: specifier: 3.1.0 - version: 3.1.0(eslint@9.39.2(jiti@2.6.1)) + version: 3.1.0(eslint@10.0.3(jiti@2.6.1)) globals: specifier: 17.4.0 version: 17.4.0 @@ -340,8 +343,8 @@ importers: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: 8.56.1 - version: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.57.1 + version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) updates: specifier: 17.8.3 version: 17.8.3 @@ -694,41 +697,41 @@ packages: eslint: optional: true - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@0.17.0': resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/eslintrc@3.3.4': resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.3': resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/json@0.14.0': - resolution: {integrity: sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/json@1.1.0': + resolution: {integrity: sha512-noH9FUYqyhZSDf3Yq5HswsjDH/MWJAatMooWwT5YgQ0XHMekoFc/iyEufP+7kD1kaOj9qwFiXySqHsKii3zmlw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@github/browserslist-config@1.0.0': resolution: {integrity: sha512-gIhjdJp/c2beaIWWIlsXdqXVRUz3r2BxBCpfz/F3JXHvSAQ1paMYjLH+maEATtENg+k5eLV7gA+9yPp762ieuw==} @@ -887,6 +890,9 @@ packages: resolution: {integrity: sha512-3dsKlf4Ma7o+uxLIg5OI1Tgwfet2pE8WTbPjEGWvOe6CSjMtK0skJnnSVHaEVX4N4mYU81To0qDeZOPqjaUotg==} engines: {node: '>=12.4.0'} + '@package-json/types@0.0.12': + resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1296,8 +1302,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/eslint-plugin@8.57.1': + resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.1': + resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1309,16 +1323,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.57.1': + resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.57.1': + resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.57.1': + resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.56.1': resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1326,16 +1356,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.57.1': + resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/types@8.56.1': resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.57.1': + resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1343,10 +1390,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.57.1': + resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.57.1': + resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -1457,8 +1515,8 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 - '@vitest/eslint-plugin@1.6.9': - resolution: {integrity: sha512-9WfPx1OwJ19QLCSRLkqVO7//1WcWnK3fE/3fJhKMAmDe8+9G4rB47xCNIIeCq3FdEzkIoLTfDlwDlPBaUTMhow==} + '@vitest/eslint-plugin@1.6.12': + resolution: {integrity: sha512-4kI47BJNFE+EQ5bmPbHzBF+ibNzx2Fj0Jo9xhWsTPxMddlHwIWl6YAxagefh461hrwx/W0QwBZpxGS404kBXyg==} engines: {node: '>=18'} peerDependencies: eslint: '>=8.57.0' @@ -2363,12 +2421,18 @@ packages: eslint-import-resolver-webpack: optional: true - eslint-plugin-array-func@5.1.0: - resolution: {integrity: sha512-+OULB0IQdENBmBf8pHMPPObgV6QyfeXFin483jPonOaiurI9UFmc8UydWriK5f5Gel8xBhQLA6NzMwbck1BUJw==} + eslint-plugin-array-func@5.1.1: + resolution: {integrity: sha512-TbVGk+yLqXHgtrS4DnYzg2Ycuk5y+lYFy5NgT748neQdJvNIYUucxp2QQjPU7dwbs9xp9fyktgtK069y9rNdig==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: '>=8.51.0' + eslint-plugin-de-morgan@2.1.1: + resolution: {integrity: sha512-0CeQ38b8hMMa3gO5vLnGcQS/xatvYno9RvjKoZ4UVaHjzvHZhR9i6+0ZkZhbbZF1FW7rqrS2MtcH3tVBejrmHQ==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-escompat@3.11.4: resolution: {integrity: sha512-j0ywwNnIufshOzgAu+PfIig1c7VRClKSNKzpniMT2vXQ4leL5q+e/SpMFQU0nrdL2WFFM44XmhSuwmxb3G0CJg==} peerDependencies: @@ -2396,12 +2460,12 @@ packages: peerDependencies: eslint: '>=5.0.0' - eslint-plugin-import-x@4.16.1: - resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==} + eslint-plugin-import-x@4.16.2: + resolution: {integrity: sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/utils': ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint-import-resolver-node: '*' peerDependenciesMeta: '@typescript-eslint/utils': @@ -2429,8 +2493,8 @@ packages: resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} engines: {node: '>=5.0.0'} - eslint-plugin-playwright@2.9.0: - resolution: {integrity: sha512-k3xrG6YzrallWNFMoGUjMNeu3SFFKXN79KJQBD2PkM4PasJegqV2Up+mPY5od2UmPKQGT+MeIhCmWH8r5eYuQQ==} + eslint-plugin-playwright@2.10.1: + resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==} engines: {node: '>=16.9.0'} peerDependencies: eslint: '>=8.40.0' @@ -2449,14 +2513,14 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-regexp@3.0.0: - resolution: {integrity: sha512-iW7hgAV8NOG6E2dz+VeKpq67YLQ9jaajOKYpoOSic2/q8y9BMdXBKkSR9gcMtbqEhNQzdW41E3wWzvhp8ExYwQ==} + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: '>=9.38.0' - eslint-plugin-sonarjs@4.0.1: - resolution: {integrity: sha512-lmqzFTrw0/zpHQMRmwdgdEEw50s3md0c8RE23JqNom9ovsGQxC/azZ9H00aGKVDkxIXywfcxwzyFJ9Sm3bp2ng==} + eslint-plugin-sonarjs@4.0.2: + resolution: {integrity: sha512-BTcT1zr1iTbmJtVlcesISwnXzh+9uhf9LEOr+RRNf4kR8xA0HQTPft4oiyOCzCOGKkpSJxjR8ZYF6H7VPyplyw==} peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -2507,14 +2571,14 @@ packages: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-scope@9.1.1: resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2527,9 +2591,9 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -4000,8 +4064,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.57.1: + resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4555,41 +4619,45 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.0.3(jiti@2.6.1))': dependencies: escape-string-regexp: 4.0.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) ignore: 7.0.5 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3(jiti@2.6.1))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint/compat@1.4.1(eslint@10.0.3(jiti@2.6.1))': dependencies: '@eslint/core': 0.17.0 optionalDependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.23.3': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.3 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.3': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.1 '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.3.4': dependencies: ajv: 6.14.0 @@ -4604,22 +4672,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} - '@eslint/js@9.39.3': {} - '@eslint/json@0.14.0': + '@eslint/json@1.1.0': dependencies: - '@eslint/core': 0.17.0 - '@eslint/plugin-kit': 0.4.1 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 '@humanwhocodes/momoa': 3.3.10 natural-compare: 1.4.0 - '@eslint/object-schema@2.1.7': {} + '@eslint/object-schema@3.0.3': {} - '@eslint/plugin-kit@0.4.1': + '@eslint/plugin-kit@0.6.1': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.1 levn: 0.4.1 '@github/browserslist-config@1.0.0': {} @@ -4770,6 +4836,8 @@ snapshots: dependencies: '@nolyfill/shared': 1.0.44 + '@package-json/types@0.0.12': {} + '@pkgr/core@0.2.9': {} '@playwright/test@1.58.2': @@ -4892,11 +4960,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.2(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@10.0.3(jiti@2.6.1))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@typescript-eslint/types': 8.56.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -5130,15 +5198,15 @@ snapshots: dependencies: '@types/node': 25.3.5 - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5146,14 +5214,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/type-utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + eslint: 10.0.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5167,22 +5251,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.57.1': + dependencies: + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.3(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5190,6 +5304,8 @@ snapshots: '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.57.1': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) @@ -5205,13 +5321,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5221,6 +5363,11 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.57.1': + dependencies: + '@typescript-eslint/types': 8.57.1 + eslint-visitor-keys: 5.0.1 + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -5286,11 +5433,11 @@ snapshots: vite: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.29(typescript@5.9.3) - '@vitest/eslint-plugin@1.6.9(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/eslint-plugin@1.6.12(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 vitest: 4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) @@ -6181,9 +6328,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -6200,10 +6347,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 @@ -6211,87 +6358,92 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.0.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-array-func@5.1.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-array-func@5.1.1(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) - eslint-plugin-escompat@3.11.4(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-de-morgan@2.1.1(eslint@10.0.3(jiti@2.6.1)): + dependencies: + eslint: 10.0.3(jiti@2.6.1) + + eslint-plugin-escompat@3.11.4(eslint@10.0.3(jiti@2.6.1)): dependencies: browserslist: 4.28.1 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) - eslint-plugin-eslint-comments@3.2.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-eslint-comments@3.2.0(eslint@10.0.3(jiti@2.6.1)): dependencies: escape-string-regexp: 1.0.5 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) ignore: 5.3.2 - eslint-plugin-filenames@1.3.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-filenames@1.3.2(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) lodash.camelcase: 4.3.0 lodash.kebabcase: 4.1.1 lodash.snakecase: 4.1.1 lodash.upperfirst: 4.3.1 - eslint-plugin-github@6.0.0(@types/eslint@9.6.1)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-github@6.0.0(@types/eslint@9.6.1)(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)): dependencies: - '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint/compat': 1.4.1(eslint@10.0.3(jiti@2.6.1)) '@eslint/eslintrc': 3.3.4 '@eslint/js': 9.39.3 '@github/browserslist-config': 1.0.0 - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) aria-query: 5.3.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-escompat: 3.11.4(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-eslint-comments: 3.2.0(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-filenames: 1.3.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-i18n-text: 1.0.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) + eslint: 10.0.3(jiti@2.6.1) + eslint-config-prettier: 10.1.8(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-escompat: 3.11.4(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-eslint-comments: 3.2.0(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-filenames: 1.3.2(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-i18n-text: 1.0.1(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-no-only-tests: 3.3.0 - eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1) eslint-rule-documentation: 1.0.23 globals: 16.5.0 jsx-ast-utils: 3.3.5 prettier: 3.8.1 svg-element-attributes: 1.3.1 typescript: 5.9.3 - typescript-eslint: 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - '@types/eslint' - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-i18n-text@1.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-i18n-text@1.0.1(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)): dependencies: + '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.56.1 comment-parser: 1.4.5 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.2.4 @@ -6299,12 +6451,12 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: '@nolyfill/array-includes@1.0.44' @@ -6313,9 +6465,9 @@ snapshots: array.prototype.flatmap: '@nolyfill/array.prototype.flatmap@1.0.44' debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.0.3(jiti@2.6.1)) hasown: '@nolyfill/hasown@1.0.44' is-core-module: '@nolyfill/is-core-module@1.0.39' is-glob: 4.0.3 @@ -6327,13 +6479,13 @@ snapshots: string.prototype.trimend: '@nolyfill/string.prototype.trimend@1.0.44' tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.0.3(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: '@nolyfill/array-includes@1.0.44' @@ -6343,7 +6495,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) hasown: '@nolyfill/hasown@1.0.44' jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -6354,38 +6506,38 @@ snapshots: eslint-plugin-no-only-tests@3.3.0: {} - eslint-plugin-playwright@2.9.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-playwright@2.10.1(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) globals: 17.4.0 - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@10.0.3(jiti@2.6.1)) - eslint-plugin-regexp@3.0.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-regexp@3.1.0(eslint@10.0.3(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 comment-parser: 1.4.5 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) jsdoc-type-pratt-parser: 7.1.1 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-sonarjs@4.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-sonarjs@4.0.2(eslint@10.0.3(jiti@2.6.1)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) functional-red-black-tree: 1.0.1 globals: 17.4.0 jsx-ast-utils-x: 0.1.0 @@ -6396,15 +6548,15 @@ snapshots: ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 - eslint-plugin-unicorn@63.0.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-unicorn@63.0.0(eslint@10.0.3(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.48.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) find-up-simple: 1.0.1 globals: 16.5.0 indent-string: 5.0.0 @@ -6416,33 +6568,33 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-vue-scoped-css@3.0.0(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1))): + eslint-plugin-vue-scoped-css@3.0.0(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + eslint: 10.0.3(jiti@2.6.1) lodash: 4.17.23 postcss: 8.5.8 postcss-safe-parser: 7.0.1(postcss@8.5.8) postcss-selector-parser: 7.1.1 - vue-eslint-parser: 10.4.0(eslint@9.39.2(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.0.3(jiti@2.6.1)) - eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.0.3(jiti@2.6.1)))(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + eslint: 10.0.3(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@9.39.2(jiti@2.6.1)) + vue-eslint-parser: 10.4.0(eslint@10.0.3(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.0.3(jiti@2.6.1)) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-wc@3.1.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-wc@3.1.0(eslint@10.0.3(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) is-valid-element-name: 1.0.0 js-levenshtein-esm: 2.0.0 @@ -6453,12 +6605,14 @@ snapshots: esrecurse: 4.3.0 estraverse: 4.3.0 - eslint-scope@8.4.0: + eslint-scope@9.1.1: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 - eslint-scope@9.1.1: + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 '@types/estree': 1.0.8 @@ -6471,28 +6625,25 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@10.0.3(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 ajv: 6.14.0 - chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -6503,8 +6654,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -8033,13 +8183,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8185,10 +8335,10 @@ snapshots: chart.js: 4.5.1 vue: 3.5.29(typescript@5.9.3) - vue-eslint-parser@10.4.0(eslint@9.39.2(jiti@2.6.1)): + vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 10.0.3(jiti@2.6.1) eslint-scope: 9.1.1 eslint-visitor-keys: 5.0.1 espree: 11.1.1 diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index d7e7203a857..76facd769f2 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -428,7 +428,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { for _, artifact := range artifacts { var downloadURL string if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil) + u, err := ar.fs.ServeDirectURL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil) if err != nil && !errors.Is(err, storage.ErrURLNotSupported) { log.Error("Error getting serve direct url: %v", err) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index d208785f638..62605f27022 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -562,7 +562,8 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { respData := GetSignedArtifactURLResponse{} if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil) + // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint + u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, nil) if u != nil && err == nil { respData.SignedUrl = u.String() } diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index a6512181e06..0726302d41f 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -26,6 +26,7 @@ import ( packages_module "code.gitea.io/gitea/modules/packages" container_module "code.gitea.io/gitea/modules/packages/container" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/packages/helper" auth_service "code.gitea.io/gitea/services/auth" @@ -706,9 +707,9 @@ func DeleteManifest(ctx *context.Context) { } func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { - serveDirectReqParams := make(url.Values) - serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType)) - s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, serveDirectReqParams) + s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{ + ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), + }) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index aa40f413ead..1c7e6b6db3e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1264,6 +1264,7 @@ func Routes() *web.Router { m.Get("", repo.GetWorkflowRun) m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) + m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) m.Get("/jobs", repo.ListWorkflowRunJobs) m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) m.Get("/artifacts", repo.GetArtifactsOfRun) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index d7e51b80469..d704092051b 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1255,7 +1255,7 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, nil); err != nil { + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil { handleWorkflowRerunError(ctx, err) return } @@ -1268,6 +1268,52 @@ func RerunWorkflowRun(ctx *context.APIContext) { ctx.JSON(http.StatusCreated, convertedRun) } +// RerunFailedWorkflowRun Reruns all failed jobs in a workflow run. +func RerunFailedWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs repository rerunFailedWorkflowRun + // --- + // summary: Reruns all failed jobs in a workflow run + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: id of the run + // type: integer + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { + return + } + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { + handleWorkflowRerunError(ctx, err) + return + } + + ctx.Status(http.StatusCreated) +} + // RerunWorkflowJob Reruns a specific workflow job in a run. func RerunWorkflowJob(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun repository rerunWorkflowJob @@ -1321,7 +1367,7 @@ func RerunWorkflowJob(ctx *context.APIContext) { } targetJob := jobs[jobIdx] - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil { handleWorkflowRerunError(ctx, err) return } diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index e31bc24eef6..d0596d778b7 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -207,7 +207,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 704b777dbf7..2dbf072f3a8 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -496,16 +496,25 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool { } if ctx.opts.UserID == user_model.ActionsUserID { - ctx.user = user_model.NewActionsUser() - ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm) - if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { - log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err) + taskID := ctx.opts.ActionsTaskID + ctx.user = user_model.NewActionsUserWithTaskID(taskID) + if taskID == 0 { + log.Error("HookPreReceive: ActionsUser with task ID 0") ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err), + Err: "ActionsUser with task ID 0", }) return false } - ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode) + + userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID) + if err != nil { + log.Error("Unable to get Actions user repo permission for task %d Error: %v", taskID, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err), + }) + return false + } + ctx.userPerm = userPerm } else { user, err := user_model.GetUserByID(ctx, ctx.opts.UserID) if err != nil { diff --git a/routers/web/admin/badges.go b/routers/web/admin/badges.go new file mode 100644 index 00000000000..8a5d3d3eb28 --- /dev/null +++ b/routers/web/admin/badges.go @@ -0,0 +1,317 @@ +// Copyright 2026 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplBadges templates.TplName = "admin/badge/list" + tplBadgeNew templates.TplName = "admin/badge/new" + tplBadgeView templates.TplName = "admin/badge/view" + tplBadgeEdit templates.TplName = "admin/badge/edit" + tplBadgeUsers templates.TplName = "admin/badge/users" +) + +// BadgeSearchDefaultAdminSort is the default sort type for admin view +const BadgeSearchDefaultAdminSort = "oldest" + +// Badges show all the badges +func Badges(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges") + ctx.Data["PageIsAdminBadges"] = true + + RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{ + ListOptions: db.ListOptions{ + Page: max(ctx.FormInt("page"), 1), + PageSize: setting.UI.Admin.UserPagingNum, + }, + }, tplBadges) +} + +// NewBadge render adding a new badge +func NewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge") + ctx.Data["PageIsAdminBadges"] = true + + ctx.HTML(http.StatusOK, tplBadgeNew) +} + +// NewBadgePost response for adding a new badge +func NewBadgePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm) + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + b := &user_model.Badge{ + Slug: form.Slug, + Description: form.Description, + ImageURL: form.ImageURL, + } + + if err := user_model.CreateBadge(ctx, b); err != nil { + if errors.Is(err, util.ErrAlreadyExist) { + ctx.JSONError(ctx.Tr("admin.badges.slug_been_taken")) + } else { + ctx.ServerError("CreateBadge", err) + } + return + } + + log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug)) + ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(b.Slug)) +} + +func prepareBadgeInfo(ctx *context.Context) *user_model.Badge { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") + } else { + ctx.ServerError("GetBadge", err) + } + return nil + } + ctx.Data["Badge"] = b + return b +} + +func ViewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.details") + ctx.Data["PageIsAdminBadges"] = true + + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + badge := ctx.Data["Badge"].(*user_model.Badge) + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + ctx.Data["Users"] = users + ctx.Data["UsersTotal"] = int(count) + + ctx.HTML(http.StatusOK, tplBadgeView) +} + +// EditBadge show editing badge page +func EditBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badge") + ctx.Data["PageIsAdminBadges"] = true + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBadgeEdit) +} + +// EditBadgePost response for editing badge +func EditBadgePost(ctx *context.Context) { + b := prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AdminEditBadgeForm) + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + b.ImageURL = form.ImageURL + b.Description = form.Description + + if err := user_model.UpdateBadge(ctx, b); err != nil { + ctx.ServerError("UpdateBadge", err) + return + } + + log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.update_success")) + ctx.JSONRedirect(setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug"))) +} + +// DeleteBadge response for deleting a badge +func DeleteBadge(ctx *context.Context) { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + ctx.ServerError("GetBadge", err) + return + } + + if err = user_model.DeleteBadge(ctx, b); err != nil { + ctx.ServerError("DeleteBadge", err) + return + } + + log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") +} + +func BadgeUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug")) + ctx.Data["PageIsAdminBadges"] = true + + page := max(ctx.FormInt("page"), 1) + + badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")} + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + + ctx.Data["Users"] = users + ctx.Data["Total"] = count + ctx.Data["Page"] = context.NewPagination(count, setting.UI.Admin.UserPagingNum, page, 5) + + ctx.HTML(http.StatusOK, tplBadgeUsers) +} + +// BadgeUsersPost response for actions for user badges +func BadgeUsersPost(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("user")) + + u, err := user_model.GetUserByName(ctx, name) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Flash.Error(ctx.Tr("admin.badges.not_found")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else if errors.Is(err, util.ErrAlreadyExist) { + ctx.Flash.Error(ctx.Tr("admin.badges.user_already_has")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("AddUserBadge", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// DeleteBadgeUser delete a badge from a user +func DeleteBadgeUser(ctx *context.Context) { + badgeUsersURL := setting.AppSubURL + "/-/admin/badges/slug/" + url.PathEscape(ctx.PathParam("badge_slug")) + "/users" + + user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.JSONRedirect(badgeUsersURL) + return + } else { + ctx.ServerError("GetUserByID", err) + return + } + } + if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil { + ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success")) + } else { + ctx.ServerError("RemoveUserBadge", err) + return + } + + ctx.JSONRedirect(badgeUsersURL) +} + +func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) { + var ( + badges []*user_model.Badge + count int64 + err error + orderBy db.SearchOrderBy + ) + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = BadgeSearchDefaultAdminSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { + case "newest": + orderBy = "`badge`.id DESC" + case "oldest": + orderBy = "`badge`.id ASC" + case "reversealphabetically": + orderBy = "`badge`.slug DESC" + case "alphabetically": + orderBy = "`badge`.slug ASC" + default: + // In case the sort type is invalid, keep admin default sorting. + ctx.Data["SortType"] = "oldest" + orderBy = "`badge`.id ASC" + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + badges, count, err = user_model.SearchBadges(ctx, opts) + if err != nil { + ctx.ServerError("SearchBadges", err) + return + } + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Badges"] = badges + + pager := context.NewPagination(count, opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go deleted file mode 100644 index 4b89237364e..00000000000 --- a/routers/web/admin/runners.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package admin - -import ( - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners") -} diff --git a/routers/web/base.go b/routers/web/base.go index db96432bedc..6afcac62e23 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -69,7 +69,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto // So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it. // Checking "stat" requires one more request to the storage, which is inefficient. // Workaround: disable "SERVE_DIRECT". Leave the problem to the future. - u, err := objStore.URL(avatarPath, path.Base(avatarPath), req.Method, nil) + u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil) if handleError(w, req, avatarPath, err) { return } diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0dd33425dc2..00ca095e716 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -59,15 +59,14 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt } func MockActionsView(ctx *context.Context) { - ctx.Data["RunID"] = ctx.PathParam("run") - ctx.Data["JobID"] = ctx.PathParam("job") + ctx.Data["RunID"] = ctx.PathParamInt64("run") + ctx.Data["JobID"] = ctx.PathParamInt64("job") ctx.HTML(http.StatusOK, "devtest/repo-action-view") } func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") - req := web.GetForm(ctx).(*actions.ViewRequest) resp := &actions.ViewResponse{} resp.State.Run.TitleHTML = `mock run title link` resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10) @@ -75,9 +74,13 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.CanCancel = runID == 10 resp.State.Run.CanApprove = runID == 20 resp.State.Run.CanRerun = runID == 30 + resp.State.Run.CanRerunFailed = runID == 30 resp.State.Run.CanDeleteArtifact = true resp.State.Run.WorkflowID = "workflow-id" resp.State.Run.WorkflowLink = "./workflow-link" + resp.State.Run.Duration = "1h 23m 45s" + resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix() + resp.State.Run.TriggerEvent = "push" resp.State.Run.Commit = actions.ViewCommit{ ShortSha: "ccccdddd", Link: "./commit-link", @@ -139,6 +142,17 @@ func MockActionsRunsJobs(ctx *context.Context) { Needs: []string{"job-100", "job-101"}, }) + fillViewRunResponseCurrentJob(ctx, resp) + ctx.JSON(http.StatusOK, resp) +} + +func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) { + jobID := ctx.PathParamInt64("job") + if jobID == 0 { + return + } + + req := web.GetForm(ctx).(*actions.ViewRequest) var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ Summary: "step 0 (mock slow)", @@ -162,7 +176,6 @@ func MockActionsRunsJobs(ctx *context.Context) { mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3}) if len(req.LogCursors) == 0 { - ctx.JSON(http.StatusOK, resp) return } @@ -188,5 +201,4 @@ func MockActionsRunsJobs(ctx *context.Context) { } else { time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine } - ctx.JSON(http.StatusOK, resp) } diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 3d2f624263b..a50d9130ac2 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -51,6 +51,12 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request } } +func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, target, http.StatusSeeOther) + } +} + func WebBannerDismiss(ctx *context.Context) { _, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx) middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600) diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go deleted file mode 100644 index fe05709237d..00000000000 --- a/routers/web/org/setting/runners.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners") -} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 2685dd8857b..98d86f0bb34 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -38,41 +38,54 @@ import ( "github.com/nektos/act/pkg/model" ) -func getRunID(ctx *context_module.Context) int64 { - // if run param is "latest", get the latest run id - if ctx.PathParam("run") == "latest" { - if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil { - return run.ID +func findCurrentJobByPathParam(ctx *context_module.Context, jobs []*actions_model.ActionRunJob) (job *actions_model.ActionRunJob, hasPathParam bool) { + selectedJobID := ctx.PathParamInt64("job") + if selectedJobID <= 0 { + return nil, false + } + for _, job = range jobs { + if job.ID == selectedJobID { + return job, true } } - return ctx.PathParamInt64("run") + return nil, true +} + +func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.ActionRun) { + var err error + // if run param is "latest", get the latest run id + if ctx.PathParam("run") == "latest" { + run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) + } else { + run, err = actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("run")) + } + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound(nil) + } else if err != nil { + ctx.ServerError("GetRun:"+ctx.PathParam("run"), err) + } + return run } func View(ctx *context_module.Context) { ctx.Data["PageIsActions"] = true - runID := getRunID(ctx) - - _, _, current := getRunJobsAndCurrentJob(ctx, runID) + run := getCurrentRunByPathParam(ctx) if ctx.Written() { return } - - ctx.Data["RunID"] = runID - ctx.Data["JobID"] = current.ID + ctx.Data["RunID"] = run.ID + ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view) ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" ctx.HTML(http.StatusOK, tplViewActions) } func ViewWorkflowFile(ctx *context_module.Context) { - runID := getRunID(ctx) - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if err != nil { - ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool { - return errors.Is(err, util.ErrNotExist) - }, err) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { return } + commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA) if err != nil { ctx.NotFoundOrServerError("GetCommit", func(err error) bool { @@ -122,6 +135,7 @@ type ViewResponse struct { CanCancel bool `json:"canCancel"` CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve CanRerun bool `json:"canRerun"` + CanRerunFailed bool `json:"canRerunFailed"` CanDeleteArtifact bool `json:"canDeleteArtifact"` Done bool `json:"done"` WorkflowID string `json:"workflowID"` @@ -129,6 +143,10 @@ type ViewResponse struct { IsSchedule bool `json:"isSchedule"` Jobs []*ViewJob `json:"jobs"` Commit ViewCommit `json:"commit"` + // Summary view: run duration and trigger time/event + Duration string `json:"duration"` + TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time + TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule } `json:"run"` CurrentJob struct { Title string `json:"title"` @@ -189,11 +207,7 @@ type ViewStepLogLine struct { } func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) { - run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) - if err != nil { - return nil, err - } - artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID) + artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID) if err != nil { return nil, err } @@ -208,10 +222,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact } func ViewPost(ctx *context_module.Context) { - req := web.GetForm(ctx).(*ViewRequest) - runID := getRunID(ctx) - - run, jobs, current := getRunJobsAndCurrentJob(ctx, runID) + run, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } @@ -220,14 +231,24 @@ func ViewPost(ctx *context_module.Context) { return } - var err error resp := &ViewResponse{} - resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID) + fillViewRunResponseSummary(ctx, resp, run, jobs) + if ctx.Written() { + return + } + fillViewRunResponseCurrentJob(ctx, resp, run, jobs) + if ctx.Written() { + return + } + ctx.JSON(http.StatusOK, resp) +} + +func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { + var err error + resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID) if err != nil { - if !errors.Is(err, util.ErrNotExist) { - ctx.ServerError("getActionsViewArtifacts", err) - return - } + ctx.ServerError("getActionsViewArtifacts", err) + return } // the title for the "run" is from the commit message @@ -238,6 +259,14 @@ func ViewPost(ctx *context_module.Context) { resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + if resp.State.Run.CanRerun { + for _, job := range jobs { + if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { + resp.State.Run.CanRerunFailed = true + break + } + } + } resp.State.Run.Done = run.Status.IsDone() resp.State.Run.WorkflowID = run.WorkflowID resp.State.Run.WorkflowLink = run.WorkflowLink() @@ -280,6 +309,20 @@ func ViewPost(ctx *context_module.Context) { Pusher: pusher, Branch: branch, } + resp.State.Run.Duration = run.Duration().String() + resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() + resp.State.Run.TriggerEvent = run.TriggerEvent +} + +func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { + req := web.GetForm(ctx).(*ViewRequest) + current, hasPathParam := findCurrentJobByPathParam(ctx, jobs) + if current == nil { + if hasPathParam { + ctx.NotFound(nil) + } + return + } var task *actions_model.ActionTask if current.TaskID > 0 { @@ -312,8 +355,6 @@ func ViewPost(ctx *context_module.Context) { resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...) resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...) } - - ctx.JSON(http.StatusOK, resp) } func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) { @@ -398,36 +439,65 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors return viewJobs, logs, nil } -// Rerun will rerun jobs in the given run -// If jobIDStr is a blank string, it means rerun all jobs -func Rerun(ctx *context_module.Context) { - runID := getRunID(ctx) - - run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID) - if ctx.Written() { - return - } - - // rerun is not allowed if the run is not done +// checkRunRerunAllowed checks whether a rerun is permitted for the given run, +// writing the appropriate JSON error to ctx and returning false when it is not. +func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.ActionRun) bool { if !run.Status.IsDone() { ctx.JSONError(ctx.Locale.Tr("actions.runs.not_done")) - return + return false } - - // can not rerun job when workflow is disabled cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(run.WorkflowID) { ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) + return false + } + return true +} + +// Rerun will rerun jobs in the given run +// If jobIDStr is a blank string, it means rerun all jobs +func Rerun(ctx *context_module.Context) { + run, jobs := getCurrentRunJobsByPathParam(ctx) + if ctx.Written() { + return + } + if !checkRunRerunAllowed(ctx, run) { return } - var targetJob *actions_model.ActionRunJob // nil means rerun all jobs - if ctx.PathParam("job") != "" { - targetJob = currentJob + currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs) + if hasPathParam && currentJob == nil { + ctx.NotFound(nil) + return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { + var jobsToRerun []*actions_model.ActionRunJob + if currentJob != nil { + jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs) + } else { + jobsToRerun = jobs + } + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil { + ctx.ServerError("RerunWorkflowRunJobs", err) + return + } + + ctx.JSONOK() +} + +// RerunFailed reruns all failed jobs in the given run +func RerunFailed(ctx *context_module.Context) { + run, jobs := getCurrentRunJobsByPathParam(ctx) + if ctx.Written() { + return + } + if !checkRunRerunAllowed(ctx, run) { + return + } + + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { ctx.ServerError("RerunWorkflowRunJobs", err) return } @@ -436,18 +506,13 @@ func Rerun(ctx *context_module.Context) { } func Logs(ctx *context_module.Context) { - runID := getRunID(ctx) - jobID := ctx.PathParamInt64("job") - - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if err != nil { - ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool { - return errors.Is(err, util.ErrNotExist) - }, err) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { return } + jobID := ctx.PathParamInt64("job") - if err = common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil { + if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil { ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) @@ -455,9 +520,7 @@ func Logs(ctx *context_module.Context) { } func Cancel(ctx *context_module.Context) { - runID := getRunID(ctx) - - run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID) + run, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } @@ -491,9 +554,11 @@ func Cancel(ctx *context_module.Context) { } func Approve(ctx *context_module.Context) { - runID := getRunID(ctx) - - approveRuns(ctx, []int64{runID}) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { + return + } + approveRuns(ctx, []int64{run.ID}) if ctx.Written() { return } @@ -568,16 +633,8 @@ func approveRuns(ctx *context_module.Context, runIDs []int64) { } func Delete(ctx *context_module.Context) { - runID := getRunID(ctx) - repoID := ctx.Repo.Repository.ID - - run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.JSONErrorNotFound() - return - } - ctx.ServerError("GetRunByRepoAndID", err) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { return } @@ -594,59 +651,37 @@ func Delete(ctx *context_module.Context) { ctx.JSONOK() } -// getRunJobsAndCurrentJob loads the run and its jobs for runID, and returns the selected job based on the optional "job" path param (or the first job by default). -// Any error will be written to the ctx, and nils are returned in that case. -func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) { - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if err != nil { - ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool { - return errors.Is(err, util.ErrNotExist) - }, err) - return nil, nil, nil +// getRunJobs loads the run and its jobs for runID +// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil. +func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) { + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { + return nil, nil } run.Repo = ctx.Repo.Repository jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) - return nil, nil, nil + return nil, nil } if len(jobs) == 0 { ctx.NotFound(nil) - return nil, nil, nil + return nil, nil } for _, job := range jobs { job.Run = run } - - current := jobs[0] - if ctx.PathParam("job") != "" { - jobID := ctx.PathParamInt64("job") - current, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID) - if err != nil { - ctx.NotFoundOrServerError("GetRunJobByRunAndID", func(err error) bool { - return errors.Is(err, util.ErrNotExist) - }, err) - return nil, nil, nil - } - current.Run = run - } - - return run, jobs, current + return run, jobs } func ArtifactsDeleteView(ctx *context_module.Context) { - runID := getRunID(ctx) - artifactName := ctx.PathParam("artifact_name") - - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if err != nil { - ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool { - return errors.Is(err, util.ErrNotExist) - }, err) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { return } - if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { + artifactName := ctx.PathParam("artifact_name") + if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { ctx.ServerError("SetArtifactNeedDelete", err) return } @@ -654,19 +689,12 @@ func ArtifactsDeleteView(ctx *context_module.Context) { } func ArtifactsDownloadView(ctx *context_module.Context) { - runID := getRunID(ctx) - artifactName := ctx.PathParam("artifact_name") - - run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) - if err != nil { - if errors.Is(err, util.ErrNotExist) { - ctx.HTTPError(http.StatusNotFound, err.Error()) - return - } - ctx.ServerError("GetRunByRepoAndID", err) + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { return } + artifactName := ctx.PathParam("artifact_name") artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ RunID: run.ID, ArtifactName: artifactName, @@ -794,7 +822,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { cfg.DisableWorkflow(workflow) } - if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil { + if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil { ctx.ServerError("UpdateRepoUnit", err) return } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index be711afe6dd..19d533f3624 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -179,7 +179,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { if setting.Attachment.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil) + u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index 9412d2045d4..073d3d74208 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -54,7 +54,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage, blob storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return nil diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index e922ed99fc8..fb9445aed03 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -59,7 +59,6 @@ func CorsHandler() func(next http.Handler) http.Handler { // httpBase does the common work for git http services, // including early response, authentication, repository lookup and permission check. func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { - username := ctx.PathParam("username") reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git") if ctx.FormString("go-get") == "1" { @@ -131,10 +130,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { // Only public pull don't need auth. isPublicPull := repoExist && !repo.IsPrivate && isPull - var ( - askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict - environ []string - ) + askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict // don't allow anonymous pulls if organization is not public if isPublicPull { @@ -184,21 +180,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } - environ = []string{ - repo_module.EnvRepoUsername + "=" + username, - repo_module.EnvRepoName + "=" + reponame, - repo_module.EnvPusherName + "=" + ctx.Doer.Name, - repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), - repo_module.EnvAppURL + "=" + setting.AppURL, - } - if repoExist { // Because of special ref "refs/for" (agit) , need delay write permission check if git.DefaultFeatures().SupportProcReceive { accessMode = perm.AccessModeRead } - if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok { + taskID, isActionsUser := user_model.GetActionsUserTaskID(ctx.Doer) + if isActionsUser { p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID) if err != nil { ctx.ServerError("GetActionsUserRepoPermission", err) @@ -209,7 +198,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { ctx.PlainText(http.StatusNotFound, "Repository not found") return nil } - environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType))) } else { p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) if err != nil { @@ -228,16 +216,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } } - - if !ctx.Doer.KeepEmailPrivate { - environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) - } - - if isWiki { - environ = append(environ, repo_module.EnvRepoIsWiki+"=true") - } else { - environ = append(environ, repo_module.EnvRepoIsWiki+"=false") - } } if !repoExist { @@ -286,7 +264,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { } } - environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + var environ []string + if !isPull { + // if not "pull", then must be "push", and doer must exist + environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) + } return &serviceHandler{serviceType, repo, isWiki, environ} } diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go index 9c2c9242d34..2237828d611 100644 --- a/routers/web/repo/setting/actions.go +++ b/routers/web/repo/setting/actions.go @@ -8,11 +8,13 @@ import ( "net/http" "strings" + "code.gitea.io/gitea/models/actions" 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" + shared_actions "code.gitea.io/gitea/routers/web/shared/actions" "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" ) @@ -34,8 +36,31 @@ func ActionsGeneralSettings(ctx *context.Context) { return } + actionsCfg := actionsUnit.ActionsConfig() + + // Token permission settings + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + + // Follow owner config (only for repos in orgs) + ctx.Data["OverrideOwnerConfig"] = actionsCfg.OverrideOwnerConfig + if actionsCfg.OverrideOwnerConfig { + ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions() + ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode + ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil + } else { + ownerActionsConfig, err := actions.GetOwnerActionsConfig(ctx, ctx.Repo.Repository.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + ctx.Data["MaxTokenPermissions"] = ownerActionsConfig.GetMaxTokenPermissions() + ctx.Data["TokenPermissionMode"] = ownerActionsConfig.TokenPermissionMode + ctx.Data["EnableMaxTokenPermissions"] = ownerActionsConfig.MaxTokenPermissions != nil + } + if ctx.Repo.Repository.IsPrivate { - collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs + collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs) if err != nil { ctx.ServerError("GetUsersByIDs", err) @@ -89,8 +114,8 @@ func AddCollaborativeOwner(ctx *context.Context) { } actionsCfg := actionsUnit.ActionsConfig() actionsCfg.AddCollaborativeOwner(ownerID) - if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { - ctx.ServerError("UpdateRepoUnit", err) + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) return } @@ -112,10 +137,59 @@ func DeleteCollaborativeOwner(ctx *context.Context) { return } actionsCfg.RemoveCollaborativeOwner(ownerID) - if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil { - ctx.ServerError("UpdateRepoUnit", err) + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) return } ctx.JSONOK() } + +// UpdateTokenPermissions updates the token permission settings for the repository +func UpdateTokenPermissions(ctx *context.Context) { + redirectURL := ctx.Repo.RepoLink + "/settings/actions/general" + + actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions) + if err != nil { + ctx.ServerError("GetUnit", err) + return + } + + actionsCfg := actionsUnit.ActionsConfig() + + // Update Override Owner Config (for repos in orgs) + // If checked, it means we WANT to override (opt-out of following) + actionsCfg.OverrideOwnerConfig = ctx.FormBool("override_owner_config") + + // Update permission mode (only if overriding owner config) + shouldUpdate := actionsCfg.OverrideOwnerConfig + + if shouldUpdate { + permissionMode, permissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode"))) + if !permissionModeValid { + ctx.Flash.Error("Invalid token permission mode") + ctx.Redirect(redirectURL) + return + } + actionsCfg.TokenPermissionMode = permissionMode + } + + // Update Maximum Permissions (radio buttons: none/read/write) + enableMaxPermissions := ctx.FormBool("enable_max_permissions") + if shouldUpdate { + if enableMaxPermissions { + actionsCfg.MaxTokenPermissions = shared_actions.ParseMaxTokenPermissions(ctx) + } else { + // If not enabled, ensure any sent permissions are ignored and set to nil + actionsCfg.MaxTokenPermissions = nil + } + } + + if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil { + ctx.ServerError("UpdateRepoUnitConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(redirectURL) +} diff --git a/routers/web/shared/actions/general.go b/routers/web/shared/actions/general.go new file mode 100644 index 00000000000..8a924f6e1fe --- /dev/null +++ b/routers/web/shared/actions/general.go @@ -0,0 +1,160 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/http" + "slices" + "strconv" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplOrgSettingsActionsGeneral templates.TplName = "org/settings/actions_general" + tplUserSettingsActionsGeneral templates.TplName = "user/settings/actions_general" +) + +// ParseMaxTokenPermissions parses the maximum token permissions from form values +func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPermissions { + parseMaxPerm := func(unitType unit.Type) perm.AccessMode { + value := ctx.FormString("max_unit_access_mode_" + strconv.Itoa(int(unitType))) + switch value { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } + } + ret := new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + for _, ut := range repo_model.ActionsTokenUnitTypes { + ret.UnitAccessModes[ut] = parseMaxPerm(ut) + } + return ret +} + +// GeneralSettings renders the actions general settings page +func GeneralSettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + if rCtx.IsOrg { + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsActionsGeneral"] = true + } else if rCtx.IsUser { + ctx.Data["PageIsUserSettings"] = true + ctx.Data["PageIsUserSettingsActionsGeneral"] = true + } else { + ctx.NotFound(nil) + return + } + + // Load User/Org Actions Config + actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + + ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode + ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive + ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted + ctx.Data["MaxTokenPermissions"] = actionsCfg.MaxTokenPermissions + if actionsCfg.MaxTokenPermissions == nil { + ctx.Data["MaxTokenPermissions"] = (&repo_model.ActionsConfig{}).GetMaxTokenPermissions() + } + ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil + + // Load Allowed Repositories + allowedRepos, err := repo_model.GetOwnerRepositoriesByIDs(ctx, rCtx.OwnerID, actionsCfg.AllowedCrossRepoIDs) + if err != nil { + ctx.ServerError("GetOwnerRepositoriesByIDs", err) + return + } + + ctx.Data["AllowedRepos"] = allowedRepos + ctx.Data["OwnerID"] = rCtx.OwnerID + + if rCtx.IsOrg { + ctx.HTML(http.StatusOK, tplOrgSettingsActionsGeneral) + } else { + ctx.HTML(http.StatusOK, tplUserSettingsActionsGeneral) + } +} + +// UpdateGeneralSettings responses for actions general settings page +func UpdateGeneralSettings(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + if !rCtx.IsOrg && !rCtx.IsUser { + ctx.NotFound(nil) + return + } + + actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID) + if err != nil { + ctx.ServerError("GetOwnerActionsConfig", err) + return + } + + if ctx.FormBool("cross_repo_add_target") { + targetRepoName := ctx.FormString("cross_repo_add_target_name") + if targetRepoName != "" { + targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.JSONError("Repository doesn't exist") + return + } + ctx.ServerError("GetRepositoryByName", err) + return + } + if !slices.Contains(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) { + actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) + } + } + } + + if crossRepoRemoveTargetID := ctx.FormInt64("cross_repo_remove_target_id"); crossRepoRemoveTargetID != 0 { + actionsCfg.AllowedCrossRepoIDs = util.SliceRemoveAll(actionsCfg.AllowedCrossRepoIDs, crossRepoRemoveTargetID) + } + + // Update Token Permission Mode + tokenPermissionMode, tokenPermissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode"))) + if tokenPermissionModeValid { + actionsCfg.TokenPermissionMode = tokenPermissionMode + enableMaxPermissions := ctx.FormBool("enable_max_permissions") + // Update Maximum Permissions (radio buttons: none/read/write) + if enableMaxPermissions { + actionsCfg.MaxTokenPermissions = ParseMaxTokenPermissions(ctx) + } else { + actionsCfg.MaxTokenPermissions = nil + } + } + + if err := actions_model.SetOwnerActionsConfig(ctx, rCtx.OwnerID, actionsCfg); err != nil { + ctx.ServerError("SetOwnerActionsConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.saved_successfully")) + ctx.JSONRedirect("") // use JSONRedirect because frontend uses form-fetch-action +} diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 8a4e93fe820..3609258440c 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -5,6 +5,7 @@ package actions import ( "errors" + "fmt" "net/http" "net/url" @@ -58,8 +59,7 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { if ctx.Data["PageIsOrgSettings"] == true { if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { - ctx.ServerError("RenderUserOrgHeader", err) - return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError + return nil, fmt.Errorf("RenderUserOrgHeader: %w", err) } return &runnersCtx{ RepoID: 0, diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go deleted file mode 100644 index 2bb10cceb95..00000000000 --- a/routers/web/user/setting/runner.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/services/context" -) - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners") -} diff --git a/routers/web/web.go b/routers/web/web.go index d53cd647456..a0bd34a29eb 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -33,7 +33,6 @@ import ( "code.gitea.io/gitea/routers/web/healthcheck" "code.gitea.io/gitea/routers/web/misc" "code.gitea.io/gitea/routers/web/org" - org_setting "code.gitea.io/gitea/routers/web/org/setting" "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" @@ -694,7 +693,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, packagesEnabled) m.Group("/actions", func() { - m.Get("", user_setting.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) + m.Group("/general", func() { + m.Get("", shared_actions.GeneralSettings) + m.Post("", shared_actions.UpdateGeneralSettings) + }) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -786,6 +789,16 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) }) + m.Group("/badges", func() { + m.Get("", admin.Badges) + m.Combo("/new").Get(admin.NewBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.NewBadgePost) + m.Get("/slug/{badge_slug}", admin.ViewBadge) + m.Combo("/slug/{badge_slug}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminEditBadgeForm{}), admin.EditBadgePost) + m.Post("/slug/{badge_slug}/delete", admin.DeleteBadge) + m.Combo("/slug/{badge_slug}/users").Get(admin.BadgeUsers).Post(admin.BadgeUsersPost) + m.Post("/slug/{badge_slug}/users/delete", admin.DeleteBadgeUser) + }) + m.Group("/emails", func() { m.Get("", admin.Emails) m.Post("/activate", admin.ActivateEmail) @@ -847,7 +860,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, oauth2Enabled) m.Group("/actions", func() { - m.Get("", admin.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/runners")) addSettingsRunnersRoutes() addSettingsVariablesRoutes() }) @@ -1004,7 +1017,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) m.Group("/actions", func() { - m.Get("", org_setting.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) + m.Group("/general", func() { + m.Get("", shared_actions.GeneralSettings) + m.Post("", shared_actions.UpdateGeneralSettings) + }) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1208,9 +1225,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/actions/general", func() { m.Get("", repo_setting.ActionsGeneralSettings) m.Post("/actions_unit", repo_setting.ActionsUnitPost) - }) + }) // doesn't require actions enabled m.Group("/actions", func() { - m.Get("", shared_actions.RedirectToDefaultSetting) + m.Get("", misc.LocationRedirect("./actions/general")) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1219,6 +1236,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/add", repo_setting.AddCollaborativeOwner) m.Post("/delete", repo_setting.DeleteCollaborativeOwner) }) + m.Post("/token_permissions", repo_setting.UpdateTokenPermissions) }) }, actions.MustEnableActions) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed @@ -1535,6 +1553,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) + m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed) }) m.Group("/workflows/{workflow_name}", func() { m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge) @@ -1734,8 +1753,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Any("/mail-preview", devtest.MailPreview) m.Any("/mail-preview/*", devtest.MailPreviewRender) m.Any("/{sub}", devtest.TmplCommon) + m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView) m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView) - m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) + m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) + m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) }) } diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go new file mode 100644 index 00000000000..9ff6134a7ac --- /dev/null +++ b/services/actions/permission_parser.go @@ -0,0 +1,141 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" + "code.gitea.io/gitea/modules/setting" + + "go.yaml.in/yaml/v4" +) + +// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job. +// It returns nil if neither workflow nor job explicitly specifies permissions. +func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions { + if flow == nil || job == nil { + return nil + } + + jobPerms := parseRawPermissionsExplicit(&job.RawPermissions) + if jobPerms != nil { + return jobPerms + } + + workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions) + if workflowPerms != nil { + return workflowPerms + } + + return nil +} + +// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes. +// It returns nil if the node does not explicitly specify permissions. +func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions { + if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") { + return nil + } + + // Unwrap DocumentNode and resolve AliasNode + node := rawPerms + for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode { + if node.Kind == yaml.DocumentNode { + if len(node.Content) == 0 { + return nil + } + node = node.Content[0] + } else { + node = node.Alias + } + } + + if node.Kind == yaml.ScalarNode && node.Value == "" { + return nil + } + + // Handle scalar values: "read-all" or "write-all" + if node.Kind == yaml.ScalarNode { + switch node.Value { + case "read-all": + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead)) + case "write-all": + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)) + default: + // Explicit but unrecognized scalar: return all-none permissions. + return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + } + } + + // Handle mapping: individual permission scopes + if node.Kind == yaml.MappingNode { + result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone) + + // Collect all scopes into a map first to handle priority + scopes := make(map[string]perm.AccessMode) + for i := 0; i < len(node.Content); i += 2 { + if i+1 >= len(node.Content) { + break + } + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode { + continue + } + + scopes[keyNode.Value] = parseAccessMode(valueNode.Value) + } + + // 1. Apply 'contents' first (lower priority) + if mode, ok := scopes["contents"]; ok { + result.UnitAccessModes[unit.TypeCode] = mode + result.UnitAccessModes[unit.TypeReleases] = mode + } + + // 2. Apply all other scopes (overwrites contents if specified) + for scope, mode := range scopes { + switch scope { + case "contents": + // already handled + case "code": + result.UnitAccessModes[unit.TypeCode] = mode + case "issues": + result.UnitAccessModes[unit.TypeIssues] = mode + case "pull-requests": + result.UnitAccessModes[unit.TypePullRequests] = mode + case "packages": + result.UnitAccessModes[unit.TypePackages] = mode + case "actions": + result.UnitAccessModes[unit.TypeActions] = mode + case "wiki": + result.UnitAccessModes[unit.TypeWiki] = mode + case "releases": + result.UnitAccessModes[unit.TypeReleases] = mode + case "projects": + result.UnitAccessModes[unit.TypeProjects] = mode + default: + setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope) + } + } + + return &result + } + + return nil +} + +// parseAccessMode converts a string access level to perm.AccessMode +func parseAccessMode(s string) perm.AccessMode { + switch s { + case "write": + return perm.AccessModeWrite + case "read": + return perm.AccessModeRead + default: + return perm.AccessModeNone + } +} diff --git a/services/actions/permission_parser_test.go b/services/actions/permission_parser_test.go new file mode 100644 index 00000000000..06352516fd8 --- /dev/null +++ b/services/actions/permission_parser_test.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/actions/jobparser" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestParseRawPermissions_ReadAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`read-all`), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_WriteAll(t *testing.T) { + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(`write-all`), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_IndividualScopes(t *testing.T) { + yamlContent := ` +contents: write +issues: read +pull-requests: none +packages: write +actions: read +wiki: write +projects: none +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypePullRequests]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages]) + assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions]) + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects]) +} + +func TestParseRawPermissions_Priority(t *testing.T) { + t.Run("granular-wins-over-contents", func(t *testing.T) { + yamlContent := ` +contents: read +code: write +releases: none +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode]) + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeReleases]) + }) + + t.Run("contents-applied-first", func(t *testing.T) { + yamlContent := ` +code: none +releases: write +contents: read +` + var rawPerms yaml.Node + err := yaml.Unmarshal([]byte(yamlContent), &rawPerms) + assert.NoError(t, err) + + result := parseRawPermissionsExplicit(&rawPerms) + require.NotNil(t, result) + + // code: none should win over contents: read + assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeCode]) + // releases: write should win over contents: read + assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeReleases]) + }) +} + +func TestParseRawPermissions_EmptyNode(t *testing.T) { + var rawPerms yaml.Node + // Empty node + + result := parseRawPermissionsExplicit(&rawPerms) + + // Should return nil for non-explicit + assert.Nil(t, result) +} + +func TestParseRawPermissions_NilNode(t *testing.T) { + result := parseRawPermissionsExplicit(nil) + + // Should return nil + assert.Nil(t, result) +} + +func TestParseAccessMode(t *testing.T) { + tests := []struct { + input string + expected perm.AccessMode + }{ + {"write", perm.AccessModeWrite}, + {"read", perm.AccessModeRead}, + {"none", perm.AccessModeNone}, + {"", perm.AccessModeNone}, + {"invalid", perm.AccessModeNone}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseAccessMode(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractJobPermissionsFromWorkflow(t *testing.T) { + workflowYAML := ` +name: Test Permissions +on: workflow_dispatch +permissions: read-all + +jobs: + job-read-only: + runs-on: ubuntu-latest + steps: + - run: echo "Full read-only" + + job-none-perms: + permissions: none + runs-on: ubuntu-latest + steps: + - run: echo "Full read-only" + + job-override: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - run: echo "Override to write" +` + + expectedPerms := map[string]*repo_model.ActionsTokenPermissions{} + expectedPerms["job-read-only"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead)) + expectedPerms["job-none-perms"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + expectedPerms["job-override"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)) + expectedPerms["job-override"].UnitAccessModes[unit.TypeCode] = perm.AccessModeWrite + expectedPerms["job-override"].UnitAccessModes[unit.TypeReleases] = perm.AccessModeWrite + + singleWorkflows, err := jobparser.Parse([]byte(workflowYAML)) + require.NoError(t, err) + for _, flow := range singleWorkflows { + jobID, jobDef := flow.Job() + require.NotNil(t, jobDef) + t.Run(jobID, func(t *testing.T) { + assert.Equal(t, expectedPerms[jobID], ExtractJobPermissionsFromWorkflow(flow, jobDef)) + }) + } +} diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 5177b90d61f..1596d9bfc5a 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -20,7 +20,27 @@ import ( "xorm.io/builder" ) -// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun +// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun +func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { + rerunJobIDSet := make(container.Set[int64]) + var jobsToRerun []*actions_model.ActionRunJob + + for _, job := range allJobs { + if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { + for _, j := range GetAllRerunJobs(job, allJobs) { + if !rerunJobIDSet.Contains(j.ID) { + rerunJobIDSet.Add(j.ID) + jobsToRerun = append(jobsToRerun, j) + } + } + } + } + + return jobsToRerun +} + +// GetAllRerunJobs returns the target job and all jobs that transitively depend on it. +// Downstream jobs are included regardless of their current status. func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { rerunJobs := []*actions_model.ActionRunJob{job} rerunJobsIDSet := make(container.Set[string]) @@ -49,12 +69,12 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A return rerunJobs } -// RerunWorkflowRunJobs reruns all done jobs of a workflow run, -// or reruns a selected job and all of its downstream jobs when targetJob is specified. -func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob) error { - // Rerun is not allowed if the run is not done. +// prepareRunRerun validates the run, resets its state, handles concurrency, persists the +// updated run, and fires a status-update notification. +// It returns isRunBlocked (true when the run itself is held by a concurrency group). +func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) { if !run.Status.IsDone() { - return util.NewInvalidArgumentErrorf("this workflow run is not done") + return false, util.NewInvalidArgumentErrorf("this workflow run is not done") } cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) @@ -62,7 +82,7 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run // Rerun is not allowed when workflow is disabled. cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(run.WorkflowID) { - return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) + return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) } // Reset run's timestamps and status. @@ -73,31 +93,31 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run vars, err := actions_model.GetVariablesOfRun(ctx, run) if err != nil { - return fmt.Errorf("get run %d variables: %w", run.ID, err) + return false, fmt.Errorf("get run %d variables: %w", run.ID, err) } if run.RawConcurrency != "" { var rawConcurrency model.RawConcurrency if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil { - return fmt.Errorf("unmarshal raw concurrency: %w", err) + return false, fmt.Errorf("unmarshal raw concurrency: %w", err) } if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil { - return err + return false, err } run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) if err != nil { - return err + return false, err } } if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { - return err + return false, err } if err := run.LoadAttributes(ctx); err != nil { - return err + return false, err } for _, job := range jobs { @@ -106,23 +126,38 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - isRunBlocked := run.Status == actions_model.StatusBlocked + return run.Status == actions_model.StatusBlocked, nil +} - if targetJob == nil { - for _, job := range jobs { - // If the job has needs, it should be blocked to wait for its dependencies. - shouldBlockJob := len(job.Needs) > 0 || isRunBlocked - if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { - return err - } - } +// RerunWorkflowRunJobs reruns the given jobs of a workflow run. +// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs). +// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its +// needs are also being rerun. +func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error { + if len(jobsToRerun) == 0 { return nil } - rerunJobs := GetAllRerunJobs(targetJob, jobs) - for _, job := range rerunJobs { - // Jobs other than the selected one should wait for dependencies. - shouldBlockJob := job.JobID != targetJob.JobID || isRunBlocked + isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun) + if err != nil { + return err + } + + rerunJobIDs := make(container.Set[string]) + for _, j := range jobsToRerun { + rerunJobIDs.Add(j.JobID) + } + + for _, job := range jobsToRerun { + shouldBlockJob := isRunBlocked + if !shouldBlockJob { + for _, need := range job.Needs { + if rerunJobIDs.Contains(need) { + shouldBlockJob = true + break + } + } + } if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { return err } diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index a98de7b7882..3b4dc5483f4 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -4,11 +4,14 @@ package actions import ( + "context" "testing" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetAllRerunJobs(t *testing.T) { @@ -46,3 +49,97 @@ func TestGetAllRerunJobs(t *testing.T) { assert.ElementsMatch(t, tc.rerunJobs, rerunJobs) } } + +func TestGetFailedRerunJobs(t *testing.T) { + // IDs must be non-zero to distinguish jobs in the dedup set. + makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { + return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs} + } + + t.Run("no failed jobs returns empty", func(t *testing.T) { + jobs := []*actions_model.ActionRunJob{ + makeJob(1, "job1", actions_model.StatusSuccess), + makeJob(2, "job2", actions_model.StatusSkipped, "job1"), + } + assert.Empty(t, GetFailedRerunJobs(jobs)) + }) + + t.Run("single failed job with no dependents", func(t *testing.T) { + job1 := makeJob(1, "job1", actions_model.StatusFailure) + job2 := makeJob(2, "job2", actions_model.StatusSuccess) + jobs := []*actions_model.ActionRunJob{job1, job2} + + result := GetFailedRerunJobs(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) + }) + + t.Run("failed job pulls in downstream dependents", func(t *testing.T) { + // job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped) + job1 := makeJob(1, "job1", actions_model.StatusFailure) + job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1") + job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2") + job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear + jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} + + result := GetFailedRerunJobs(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) + }) + + t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) { + // job1 failed -> job3 depends on job1 + // job2 failed -> job4 depends on job2 + job1 := makeJob(1, "job1", actions_model.StatusFailure) + job2 := makeJob(2, "job2", actions_model.StatusFailure) + job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1") + job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2") + jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} + + result := GetFailedRerunJobs(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result) + }) + + t.Run("shared downstream dependent is not duplicated", func(t *testing.T) { + // job1 and job2 both failed; job3 depends on both + job1 := makeJob(1, "job1", actions_model.StatusFailure) + job2 := makeJob(2, "job2", actions_model.StatusFailure) + job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2") + jobs := []*actions_model.ActionRunJob{job1, job2, job3} + + result := GetFailedRerunJobs(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) + assert.Len(t, result, 3) // job3 must appear exactly once + }) + + t.Run("successful downstream job of a failed job is still included", func(t *testing.T) { + // job1 failed; job2 succeeded but depends on job1 — downstream is always rerun + // regardless of its own status (GetAllRerunJobs includes all transitive dependents) + job1 := makeJob(1, "job1", actions_model.StatusFailure) + job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1") + jobs := []*actions_model.ActionRunJob{job1, job2} + + result := GetFailedRerunJobs(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) + }) +} + +func TestRerunValidation(t *testing.T) { + runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning} + + t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) { + jobs := []*actions_model.ActionRunJob{ + {ID: 1, JobID: "job1"}, + } + err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrInvalidArgument) + }) + + t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) { + jobs := []*actions_model.ActionRunJob{ + {ID: 1, JobID: "job1", Status: actions_model.StatusFailure}, + } + err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs)) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrInvalidArgument) + }) +} diff --git a/services/actions/run.go b/services/actions/run.go index c9eadc48d1e..e9fcdcaf43d 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -103,6 +103,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool + for _, v := range jobs { id, job := v.Job() needs := job.Needs() @@ -127,6 +128,11 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), } + // Parse workflow/job permissions (no clamping here) + if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil { + runJob.TokenPermissions = perms + } + // check job concurrency if job.RawConcurrency != nil { rawConcurrency, err := yaml.Marshal(job.RawConcurrency) diff --git a/services/actions/token_permission_design.md b/services/actions/token_permission_design.md new file mode 100644 index 00000000000..d5318606d03 --- /dev/null +++ b/services/actions/token_permission_design.md @@ -0,0 +1,123 @@ +# Actions Token Permission System Design + +This document details the design of the Actions Token Permission system within Gitea, originally proposed in [#24635](https://github.com/go-gitea/gitea/issues/24635). + +## Design Philosophy & GitHub Differences + +Gitea Actions uses a **strict clamping mechanism** for token permissions. +While workflows can request explicit permissions that exceed the repository's default baseline +(e.g., requesting `write` when the default mode is `Restricted`), +these requests are always bounded by a hard ceiling. + +The maximum allowable permissions (`MaxTokenPermissions`) are set at the Repository or Organization level. +**Any permissions requested by a workflow are strictly clamped by this ceiling policy.** +This ensures that workflows cannot bypass organizational or repository-level security restrictions. + +## Terminology + +### 1. `GITEA_TOKEN` +- The automatic token generated for each Actions job. +- Its permissions (read/write/none) are scoped to the repository and specific features (Code, Issues, etc.). + +### 2. Token Permission Mode +- The default access level granted to a token when no explicit `permissions:` block is present in a workflow. +- **Permissive**: Grants `write` access to most repository scopes by default. +- **Restricted**: Grants `read` access (or none) to repository scopes by default. + +### 3. Actions Token Permissions +- A structure representing the granular permission scopes available to a token. +- Includes scopes like: Code, Releases (both grouped under `contents` in workflow syntax), + Issues, PullRequests, Actions, Wiki, and Projects. +- **Note**: The `Packages` scope is supported in workflow/job `permissions:` blocks + but is currently hidden from the settings UI. + +### 4. Cross-Repository Access +- By default, a token can access the repository where the workflow is running, + as well as any **public repositories (read-only)** on the instance. +- Users and organizations can configure an `AllowedCrossRepoIDs` list in their owner-level settings + to grant the token **read-only** access to other private/internal repositories they own. +- If the `AllowedCrossRepoIDs` list is empty, there is no cross-repository access + to other private repositories (default for enhanced security). +- In any configuration, individual jobs can disable or limit cross-repo access + by explicitly restricting their permissions (e.g., `permissions: none`). +- **Note on Forks**: Cross-repository access to private repositories is fundamentally denied + for workflows triggered by fork pull requests (see [Special Cases](#2-fork-pull-requests)). + +## Token Lifecycle & Permission Evaluation + +When a job starts, Gitea evaluates the requested permissions for the `GITEA_TOKEN` through a multistep clamping process: + +### Step 1: Determine Base Permissions From Workflow +- If the job explicitly specifies a valid `permissions:` block, Gitea parses it. +- If the job inherits a top-level `permissions:` block, Gitea parses that. +- If an invalid or unparseable `permissions:` block is specified, or no explicit permissions are defined at all, + Gitea falls back to using the repository's default `TokenPermissionMode` (Permissive or Restricted) + to generate base permissions. + +### Step 2: Apply Repository Clamping +- Repositories can define `MaxTokenPermissions` in their Actions settings. +- The base permissions from Step 1 are clamped against these maximum allowed permissions. +- If the repository says `Issues: read` and the workflow requests `Issues: write`, the final token gets `Issues: read`. + +### Step 3: Apply Organization/User Clamping (Hierarchical Override) +- The organization (or user) has an owner-level configuration (`UserActionsConfig`) containing `MaxTokenPermissions`, + and these restrictions cascade down. +- The repository's clamping limits cannot exceed the owner's limits + UNLESS the repository explicitly enables `OverrideOwnerConfig`. +- If `OverrideOwnerConfig` is false, and the owner sets `MaxTokenPermissions` to `read` for all scopes, + no repository under that owner can grant `write` access, regardless of their own settings or the workflow's request. + +## Parsing Priority for "contents" Scope + +In GitHub Actions compatibility, the `contents` scope maps to multiple granular scopes in Gitea. +- `contents: write` maps to `Code: write` and `Releases: write`. +- When a workflow specifies both `contents` and a more granular scope (e.g., `code`), + the granular scope takes absolute priority. + +**Example YAML**: +```yaml +permissions: + contents: write + code: read +``` +**Result**: The token gets `Code: read` (from granular) and `Releases: write` (from contents). + +## Special Cases & Edge Scenarios + +### 1. Empty Permissions Mapping (`permissions: {}`) +- Explicitly setting an empty mapping means "revoke all permissions". +- The token gets `none` for all scopes. + +### 2. Fork Pull Requests +- Workflows triggered by Pull Requests from forks inherently operate in `Restricted` mode for security reasons. +- The base permissions for the current repository are automatically downgraded to `read` (or `none`), + preventing untrusted code from modifying the repository. +- **Cross-Repo Access in Forks**: For workflows triggered by fork pull requests, cross-repository access + to other private repositories is strictly denied, regardless of the `AllowedCrossRepoIDs` configuration. + Fork PRs can only read the target repository and truly public repositories. + +### 3. Public Repositories in Cross-Repo Access +- As mentioned in Cross-Repository Access, truly public repositories can always be read by the token, + regardless of the `AllowedCrossRepoIDs` setting. The allowed list only governs access + to private/internal repositories owned by the same user or organization. + +## Packages Registry + +"Packages" belong to "owner" but not "repository". Although there is a function "linking a package to a repository", +in most cases it doesn't really work. When accessing a package, usually there is no information about a repository. +So the "packages" permission should be designed separately from other permissions. + +A possible approach is like this: let owner set packages permissions, and make the repositories follow. + +- On owner-level: + - Add a "Packages" permission section + - "Default permissions for all repositories" can be set to none/read/write + - Set different permissions for selected repositories (if needed), like the "Collaborators" permission setting + +- On repository-level: + - Now a repository can have "Packages" permission + - The repository-level "Packages" permission is clamped by the owner-level "Packages" permission + - If the owner-level "Packages" permission for this repository is read, + then the repository cannot set its "Packages" permission to write + +Maybe reusing the "org teams" permission system is a good choice: bind a repository's Actions token to a team. diff --git a/services/actions/workflow.go b/services/actions/workflow.go index faa540421fb..b41741403fd 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -41,7 +41,7 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl cfg.DisableWorkflow(workflow.ID) } - return repo_model.UpdateRepoUnit(ctx, cfgUnit) + return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit) } func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) { diff --git a/services/doctor/fix16961.go b/services/doctor/fix16961.go index 50d9ac6621a..63e33350ad5 100644 --- a/services/doctor/fix16961.go +++ b/services/doctor/fix16961.go @@ -296,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo return nil } - return repo_model.UpdateRepoUnit(ctx, repoUnit) + return repo_model.UpdateRepoUnitConfig(ctx, repoUnit) }, ) if err != nil { diff --git a/services/forms/admin.go b/services/forms/admin.go index 81276f8f46f..c90ddf7d543 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -25,6 +25,31 @@ type AdminCreateUserForm struct { Visibility structs.VisibleType } +// AdminCreateBadgeForm form for admin to create badge +type AdminCreateBadgeForm struct { + Slug string `binding:"Required;BadgeSlug" locale:"admin.badges.slug"` + Description string `binding:"Required" locale:"admin.badges.description"` + ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"` +} + +// AdminEditBadgeForm form for admin to edit badge +type AdminEditBadgeForm struct { + Description string `binding:"Required" locale:"admin.badges.description"` + ImageURL string `binding:"ValidUrl" locale:"admin.badges.image_url"` +} + +// Validate validates form fields +func (f *AdminCreateBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +// Validate validates form fields +func (f *AdminEditBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // Validate validates form fields func (f *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { ctx := context.GetValidateContext(req) diff --git a/services/lfs/server.go b/services/lfs/server.go index 10b4dba222c..fc09eb58ca4 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -42,7 +42,6 @@ type requestContext struct { User string Repo string Authorization string - Method string RepoGitURL string } @@ -427,7 +426,6 @@ func getRequestContext(ctx *context.Context) *requestContext { User: ownerName, Repo: repoName, Authorization: ctx.Req.Header.Get("Authorization"), - Method: ctx.Req.Method, RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"), } } @@ -490,7 +488,8 @@ func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, downloa var link *lfs_module.Link if setting.LFS.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid, rc.Method, nil) + // DO NOT USE the http POST method coming from the lfs batch endpoint + u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), pointer.Oid, http.MethodGet, nil) if u != nil && err == nil { link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header } diff --git a/services/packages/packages.go b/services/packages/packages.go index 22b26b65637..3b4e11e0410 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -599,7 +599,7 @@ func OpenBlobStream(pb *packages_model.PackageBlob) (io.ReadSeekCloser, error) { // OpenBlobForDownload returns the content of the specific package blob and increases the download counter. // If the storage supports direct serving and it's enabled, only the direct serving url is returned. -func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams url.Values) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) { +func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams *storage.ServeDirectOptions) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) { key := packages_module.BlobHash256Key(pb.HashSHA256) cs := packages_module.NewContentStore() diff --git a/services/pull/update_test.go b/services/pull/update_test.go index 4b5772e35d5..0bb67544455 100644 --- a/services/pull/update_test.go +++ b/services/pull/update_test.go @@ -26,7 +26,7 @@ func TestIsUserAllowedToUpdate(t *testing.T) { setRepoAllowRebaseUpdate := func(t *testing.T, repoID int64, allow bool) { repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests}) repoUnit.PullRequestsConfig().AllowRebaseUpdate = allow - require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit)) + require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit)) } user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index 07214d0bfa9..1d28e00655c 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -346,7 +346,7 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error rPath := archiver.RelativePath() if setting.RepoArchive.Storage.ServeDirect() { // If we have a signed url (S3, object storage), redirect to this directly. - u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil) + u, err := storage.RepoArchives.ServeDirectURL(rPath, downloadName, ctx.Req.Method, nil) if u != nil && err == nil { ctx.Redirect(u.String()) return nil diff --git a/services/repository/template.go b/services/repository/template.go index 96033cb98d0..c49e2cdde02 100644 --- a/services/repository/template.go +++ b/services/repository/template.go @@ -33,11 +33,12 @@ func GenerateIssueLabels(ctx context.Context, templateRepo, generateRepo *repo_m newLabels := make([]*issues_model.Label, 0, len(templateLabels)) for _, templateLabel := range templateLabels { newLabels = append(newLabels, &issues_model.Label{ - RepoID: generateRepo.ID, - Name: templateLabel.Name, - Exclusive: templateLabel.Exclusive, - Description: templateLabel.Description, - Color: templateLabel.Color, + RepoID: generateRepo.ID, + Name: templateLabel.Name, + Exclusive: templateLabel.Exclusive, + ExclusiveOrder: templateLabel.ExclusiveOrder, + Description: templateLabel.Description, + Color: templateLabel.Color, }) } return db.Insert(ctx, newLabels) diff --git a/services/repository/transfer.go b/services/repository/transfer.go index a601ee6f168..fbf357c3667 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -246,6 +247,19 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName return fmt.Errorf("recalculateAccesses: %w", err) } + // Remove repository from old owner's Actions AllowedCrossRepoIDs if present + if oldActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, oldOwner.ID); err == nil { + newAllowedCrossRepoIDs := util.SliceRemoveAll(oldActionsCfg.AllowedCrossRepoIDs, repo.ID) + if len(newAllowedCrossRepoIDs) != len(oldActionsCfg.AllowedCrossRepoIDs) { + oldActionsCfg.AllowedCrossRepoIDs = newAllowedCrossRepoIDs + if err := actions_model.SetOwnerActionsConfig(ctx, oldOwner.ID, oldActionsCfg); err != nil { + return fmt.Errorf("SetOwnerActionsConfig: %w", err) + } + } + } else { + return fmt.Errorf("GetOwnerActionsConfig: %w", err) + } + // Update repository count. if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { return fmt.Errorf("increase new owner repository count: %w", err) diff --git a/templates/admin/badge/edit.tmpl b/templates/admin/badge/edit.tmpl new file mode 100644 index 00000000000..f2cdc4ad443 --- /dev/null +++ b/templates/admin/badge/edit.tmpl @@ -0,0 +1,44 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.edit_badge"}} +

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ + + +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/list.tmpl b/templates/admin/badge/list.tmpl new file mode 100644 index 00000000000..3020b7b25a1 --- /dev/null +++ b/templates/admin/badge/list.tmpl @@ -0,0 +1,67 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.badges_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}}) + +

+
+
+
+ {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}} +
+ + +
+
+
+ + + + + + + + + + + {{range .Badges}} + + + + + + + {{end}} + +
ID{{SortArrow "oldest" "newest" .SortType false}} + {{ctx.Locale.Tr "admin.badges.slug"}} + {{SortArrow "alphabetically" "reversealphabetically" $.SortType true}} + {{ctx.Locale.Tr "admin.badges.description"}}
{{.ID}} + {{.Slug}} + {{.Description}} + +
+
+ + {{template "base/paginate" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/new.tmpl b/templates/admin/badge/new.tmpl new file mode 100644 index 00000000000..5b67bed3146 --- /dev/null +++ b/templates/admin/badge/new.tmpl @@ -0,0 +1,26 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.new_badge"}} +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/users.tmpl b/templates/admin/badge/users.tmpl new file mode 100644 index 00000000000..97d332010f3 --- /dev/null +++ b/templates/admin/badge/users.tmpl @@ -0,0 +1,40 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}} +
+

+ {{.Title}} +

+
+
+ + +
+
+ {{if .Users}} +
+
+ {{range .Users}} +
+ +
+
+ {{template "shared/user/name" .}} +
+
+ +
+ {{end}} +
+
+ {{end}} + {{template "base/paginate" .}} +
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/view.tmpl b/templates/admin/badge/view.tmpl new file mode 100644 index 00000000000..efd31f4c411 --- /dev/null +++ b/templates/admin/badge/view.tmpl @@ -0,0 +1,44 @@ +{{template "admin/layout_head" (dict "ctxData" .)}} + +
+
+
+

+ {{.Title}} + +

+
+
+
+ {{if .Badge.ImageURL}} +
+ {{.Badge.Description}} +
+ {{end}} +
+
+ {{.Badge.Slug}} +
+
+ {{.Badge.Description}} +
+
+
+
+
+
+
+

+ {{ctx.Locale.Tr "explore.users"}} ({{.UsersTotal}}) + +

+
+ {{template "explore/user_list" .}} +
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec799c..ce3048ed9fa 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -13,7 +13,7 @@ -
+
{{ctx.Locale.Tr "admin.identity_access"}}