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/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/migrations/migrations.go b/models/migrations/migrations.go index c1d448577c6..dc5dc9f3308 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -402,6 +402,7 @@ 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), } 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/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..797b34de696 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" @@ -175,57 +173,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 +226,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 +245,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 +313,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/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 587a46e8dec..5867b908d1b 100644 --- a/models/user/setting_options.go +++ b/models/user/setting_options.go @@ -22,4 +22,6 @@ const ( SettingEmailNotificationGiteaActionsAll = "all" SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference SettingEmailNotificationGiteaActionsDisabled = "disabled" + + 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/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/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5a5148a146e..80744253d8b 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", @@ -645,6 +646,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", @@ -3707,6 +3709,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", @@ -3756,5 +3762,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/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 f8f59debc3e..3dfbe6f430a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1259,6 +1259,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/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 8da7609994a..6893e380df2 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" @@ -693,7 +692,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() @@ -846,7 +849,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() }) @@ -998,7 +1001,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() @@ -1202,9 +1209,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() @@ -1213,6 +1220,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 @@ -1529,6 +1537,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) @@ -1728,8 +1737,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/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/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/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl index b3a52db1e29..46f040d8a6f 100644 --- a/templates/devtest/repo-action-view.tmpl +++ b/templates/devtest/repo-action-view.tmpl @@ -1,14 +1,14 @@ {{template "base/head" .}}
-
- Run:CanCancel - Run:CanApprove - Run:CanRerun + {{template "repo/actions/view_component" (dict "RunID" (or .RunID 10) "JobID" (or .JobID 0) - "ActionsURL" (print AppSubUrl "/devtest/actions-mock") + "ActionsURL" (print AppSubUrl "/devtest/repo-action-view") )}}
{{template "base/footer" .}} diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl new file mode 100644 index 00000000000..ebf9482f614 --- /dev/null +++ b/templates/org/settings/actions_general.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" .)}} +
+ {{template "shared/actions/owner_general_settings" .}} +
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 58475de7e7a..4c06b2cb1ba 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -26,9 +26,12 @@ {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}