Add tag protection (#15629)
* Added tag protection in hook. * Prevent UI tag creation if protected. * Added settings page. * Added tests. * Added suggestions. * Moved tests. * Use individual errors. * Removed unneeded methods. * Switched delete selector. * Changed method names. * No reason to be unique. * Allow editing of protected tags. * Removed unique key from migration. * Added docs page. * Changed date. * Respond with 404 to not found tags. * Replaced glob with regex pattern. * Added support for glob and regex pattern. * Updated documentation. * Changed white* to allow*. * Fixed edit button link. * Added cancel button. Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>tokarchuk/v1.17
parent
7a0ed9a046
commit
44b8b07631
@ -0,0 +1,57 @@ |
|||||||
|
--- |
||||||
|
date: "2021-05-14T00:00:00-00:00" |
||||||
|
title: "Protected tags" |
||||||
|
slug: "protected-tags" |
||||||
|
weight: 45 |
||||||
|
toc: false |
||||||
|
draft: false |
||||||
|
menu: |
||||||
|
sidebar: |
||||||
|
parent: "advanced" |
||||||
|
name: "Protected tags" |
||||||
|
weight: 45 |
||||||
|
identifier: "protected-tags" |
||||||
|
--- |
||||||
|
|
||||||
|
# Protected tags |
||||||
|
|
||||||
|
Protected tags allow control over who has permission to create or update git tags. Each rule allows you to match either an individual tag name, or use an appropriate pattern to control multiple tags at once. |
||||||
|
|
||||||
|
**Table of Contents** |
||||||
|
|
||||||
|
{{< toc >}} |
||||||
|
|
||||||
|
## Setting up protected tags |
||||||
|
|
||||||
|
To protect a tag, you need to follow these steps: |
||||||
|
|
||||||
|
1. Go to the repository’s **Settings** > **Tags** page. |
||||||
|
1. Type a pattern to match a name. You can use a single name, a [glob pattern](https://pkg.go.dev/github.com/gobwas/glob#Compile) or a regular expression. |
||||||
|
1. Choose the allowed users and/or teams. If you leave these fields empty noone is allowed to create or modify this tag. |
||||||
|
1. Select **Save** to save the configuration. |
||||||
|
|
||||||
|
## Pattern protected tags |
||||||
|
|
||||||
|
The pattern uses [glob](https://pkg.go.dev/github.com/gobwas/glob#Compile) or regular expressions to match a tag name. For regular expressions you need to enclose the pattern in slashes. |
||||||
|
|
||||||
|
Examples: |
||||||
|
|
||||||
|
| Type | Pattern Protected Tag | Possible Matching Tags | |
||||||
|
| ----- | ------------------------ | --------------------------------------- | |
||||||
|
| Glob | `v*` | `v`, `v-1`, `version2` | |
||||||
|
| Glob | `v[0-9]` | `v0`, `v1` up to `v9` | |
||||||
|
| Glob | `*-release` | `2.1-release`, `final-release` | |
||||||
|
| Glob | `gitea` | only `gitea` | |
||||||
|
| Glob | `*gitea*` | `gitea`, `2.1-gitea`, `1_gitea-release` | |
||||||
|
| Glob | `{v,rel}-*` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` | |
||||||
|
| Glob | `*` | matches all possible tag names | |
||||||
|
| Regex | `/\Av/` | `v`, `v-1`, `version2` | |
||||||
|
| Regex | `/\Av[0-9]\z/` | `v0`, `v1` up to `v9` | |
||||||
|
| Regex | `/\Av\d+\.\d+\.\d+\z/` | `v1.0.17`, `v2.1.0` | |
||||||
|
| Regex | `/\Av\d+(\.\d+){0,2}\z/` | `v1`, `v2.1`, `v1.2.34` | |
||||||
|
| Regex | `/-release\z/` | `2.1-release`, `final-release` | |
||||||
|
| Regex | `/gitea/` | `gitea`, `2.1-gitea`, `1_gitea-release` | |
||||||
|
| Regex | `/\Agitea\z/` | only `gitea` | |
||||||
|
| Regex | `/^gitea$/` | only `gitea` | |
||||||
|
| Regex | `/\A(v\|rel)-/` | `v-`, `v-1`, `v-final`, `rel-`, `rel-x` | |
||||||
|
| Regex | `/.+/` | matches all possible tag names | |
@ -0,0 +1,74 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package integrations |
||||||
|
|
||||||
|
import ( |
||||||
|
"io/ioutil" |
||||||
|
"net/url" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models" |
||||||
|
"code.gitea.io/gitea/modules/git" |
||||||
|
"code.gitea.io/gitea/modules/util" |
||||||
|
"code.gitea.io/gitea/services/release" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestCreateNewTagProtected(t *testing.T) { |
||||||
|
defer prepareTestEnv(t)() |
||||||
|
|
||||||
|
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) |
||||||
|
owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) |
||||||
|
|
||||||
|
t.Run("API", func(t *testing.T) { |
||||||
|
defer PrintCurrentTest(t)() |
||||||
|
|
||||||
|
err := release.CreateNewTag(owner, repo, "master", "v-1", "first tag") |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
err = models.InsertProtectedTag(&models.ProtectedTag{ |
||||||
|
RepoID: repo.ID, |
||||||
|
NamePattern: "v-*", |
||||||
|
}) |
||||||
|
assert.NoError(t, err) |
||||||
|
err = models.InsertProtectedTag(&models.ProtectedTag{ |
||||||
|
RepoID: repo.ID, |
||||||
|
NamePattern: "v-1.1", |
||||||
|
AllowlistUserIDs: []int64{repo.OwnerID}, |
||||||
|
}) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
err = release.CreateNewTag(owner, repo, "master", "v-2", "second tag") |
||||||
|
assert.Error(t, err) |
||||||
|
assert.True(t, models.IsErrProtectedTagName(err)) |
||||||
|
|
||||||
|
err = release.CreateNewTag(owner, repo, "master", "v-1.1", "third tag") |
||||||
|
assert.NoError(t, err) |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("Git", func(t *testing.T) { |
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) { |
||||||
|
username := "user2" |
||||||
|
httpContext := NewAPITestContext(t, username, "repo1") |
||||||
|
|
||||||
|
dstPath, err := ioutil.TempDir("", httpContext.Reponame) |
||||||
|
assert.NoError(t, err) |
||||||
|
defer util.RemoveAll(dstPath) |
||||||
|
|
||||||
|
u.Path = httpContext.GitPath() |
||||||
|
u.User = url.UserPassword(username, userPassword) |
||||||
|
|
||||||
|
doGitClone(dstPath, u)(t) |
||||||
|
|
||||||
|
_, err = git.NewCommand("tag", "v-2").RunInDir(dstPath) |
||||||
|
assert.NoError(t, err) |
||||||
|
|
||||||
|
_, err = git.NewCommand("push", "--tags").RunInDir(dstPath) |
||||||
|
assert.Error(t, err) |
||||||
|
assert.Contains(t, err.Error(), "Tag v-2 is protected") |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package migrations |
||||||
|
|
||||||
|
import ( |
||||||
|
"code.gitea.io/gitea/modules/timeutil" |
||||||
|
|
||||||
|
"xorm.io/xorm" |
||||||
|
) |
||||||
|
|
||||||
|
func createProtectedTagTable(x *xorm.Engine) error { |
||||||
|
type ProtectedTag struct { |
||||||
|
ID int64 `xorm:"pk autoincr"` |
||||||
|
RepoID int64 |
||||||
|
NamePattern string |
||||||
|
AllowlistUserIDs []int64 `xorm:"JSON TEXT"` |
||||||
|
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"` |
||||||
|
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"` |
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` |
||||||
|
} |
||||||
|
|
||||||
|
return x.Sync2(new(ProtectedTag)) |
||||||
|
} |
@ -0,0 +1,131 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"regexp" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/base" |
||||||
|
"code.gitea.io/gitea/modules/timeutil" |
||||||
|
|
||||||
|
"github.com/gobwas/glob" |
||||||
|
) |
||||||
|
|
||||||
|
// ProtectedTag struct
|
||||||
|
type ProtectedTag struct { |
||||||
|
ID int64 `xorm:"pk autoincr"` |
||||||
|
RepoID int64 |
||||||
|
NamePattern string |
||||||
|
RegexPattern *regexp.Regexp `xorm:"-"` |
||||||
|
GlobPattern glob.Glob `xorm:"-"` |
||||||
|
AllowlistUserIDs []int64 `xorm:"JSON TEXT"` |
||||||
|
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"` |
||||||
|
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"` |
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` |
||||||
|
} |
||||||
|
|
||||||
|
// InsertProtectedTag inserts a protected tag to database
|
||||||
|
func InsertProtectedTag(pt *ProtectedTag) error { |
||||||
|
_, err := x.Insert(pt) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// UpdateProtectedTag updates the protected tag
|
||||||
|
func UpdateProtectedTag(pt *ProtectedTag) error { |
||||||
|
_, err := x.ID(pt.ID).AllCols().Update(pt) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// DeleteProtectedTag deletes a protected tag by ID
|
||||||
|
func DeleteProtectedTag(pt *ProtectedTag) error { |
||||||
|
_, err := x.ID(pt.ID).Delete(&ProtectedTag{}) |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// EnsureCompiledPattern ensures the glob pattern is compiled
|
||||||
|
func (pt *ProtectedTag) EnsureCompiledPattern() error { |
||||||
|
if pt.RegexPattern != nil || pt.GlobPattern != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
if len(pt.NamePattern) >= 2 && strings.HasPrefix(pt.NamePattern, "/") && strings.HasSuffix(pt.NamePattern, "/") { |
||||||
|
pt.RegexPattern, err = regexp.Compile(pt.NamePattern[1 : len(pt.NamePattern)-1]) |
||||||
|
} else { |
||||||
|
pt.GlobPattern, err = glob.Compile(pt.NamePattern) |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// IsUserAllowed returns true if the user is allowed to modify the tag
|
||||||
|
func (pt *ProtectedTag) IsUserAllowed(userID int64) (bool, error) { |
||||||
|
if base.Int64sContains(pt.AllowlistUserIDs, userID) { |
||||||
|
return true, nil |
||||||
|
} |
||||||
|
|
||||||
|
if len(pt.AllowlistTeamIDs) == 0 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
|
||||||
|
in, err := IsUserInTeams(userID, pt.AllowlistTeamIDs) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
return in, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetProtectedTags gets all protected tags of the repository
|
||||||
|
func (repo *Repository) GetProtectedTags() ([]*ProtectedTag, error) { |
||||||
|
tags := make([]*ProtectedTag, 0) |
||||||
|
return tags, x.Find(&tags, &ProtectedTag{RepoID: repo.ID}) |
||||||
|
} |
||||||
|
|
||||||
|
// GetProtectedTagByID gets the protected tag with the specific id
|
||||||
|
func GetProtectedTagByID(id int64) (*ProtectedTag, error) { |
||||||
|
tag := new(ProtectedTag) |
||||||
|
has, err := x.ID(id).Get(tag) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if !has { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
return tag, nil |
||||||
|
} |
||||||
|
|
||||||
|
// IsUserAllowedToControlTag checks if a user can control the specific tag.
|
||||||
|
// It returns true if the tag name is not protected or the user is allowed to control it.
|
||||||
|
func IsUserAllowedToControlTag(tags []*ProtectedTag, tagName string, userID int64) (bool, error) { |
||||||
|
isAllowed := true |
||||||
|
for _, tag := range tags { |
||||||
|
err := tag.EnsureCompiledPattern() |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
|
||||||
|
if !tag.matchString(tagName) { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
isAllowed, err = tag.IsUserAllowed(userID) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
if isAllowed { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return isAllowed, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (pt *ProtectedTag) matchString(name string) bool { |
||||||
|
if pt.RegexPattern != nil { |
||||||
|
return pt.RegexPattern.MatchString(name) |
||||||
|
} |
||||||
|
return pt.GlobPattern.Match(name) |
||||||
|
} |
@ -0,0 +1,162 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package models |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestIsUserAllowed(t *testing.T) { |
||||||
|
assert.NoError(t, PrepareTestDatabase()) |
||||||
|
|
||||||
|
pt := &ProtectedTag{} |
||||||
|
allowed, err := pt.IsUserAllowed(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, allowed) |
||||||
|
|
||||||
|
pt = &ProtectedTag{ |
||||||
|
AllowlistUserIDs: []int64{1}, |
||||||
|
} |
||||||
|
allowed, err = pt.IsUserAllowed(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, allowed) |
||||||
|
|
||||||
|
allowed, err = pt.IsUserAllowed(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, allowed) |
||||||
|
|
||||||
|
pt = &ProtectedTag{ |
||||||
|
AllowlistTeamIDs: []int64{1}, |
||||||
|
} |
||||||
|
allowed, err = pt.IsUserAllowed(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.False(t, allowed) |
||||||
|
|
||||||
|
allowed, err = pt.IsUserAllowed(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, allowed) |
||||||
|
|
||||||
|
pt = &ProtectedTag{ |
||||||
|
AllowlistUserIDs: []int64{1}, |
||||||
|
AllowlistTeamIDs: []int64{1}, |
||||||
|
} |
||||||
|
allowed, err = pt.IsUserAllowed(1) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, allowed) |
||||||
|
|
||||||
|
allowed, err = pt.IsUserAllowed(2) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.True(t, allowed) |
||||||
|
} |
||||||
|
|
||||||
|
func TestIsUserAllowedToControlTag(t *testing.T) { |
||||||
|
cases := []struct { |
||||||
|
name string |
||||||
|
userid int64 |
||||||
|
allowed bool |
||||||
|
}{ |
||||||
|
{ |
||||||
|
name: "test", |
||||||
|
userid: 1, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "test", |
||||||
|
userid: 3, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "gitea", |
||||||
|
userid: 1, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "gitea", |
||||||
|
userid: 3, |
||||||
|
allowed: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "test-gitea", |
||||||
|
userid: 1, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "test-gitea", |
||||||
|
userid: 3, |
||||||
|
allowed: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "gitea-test", |
||||||
|
userid: 1, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "gitea-test", |
||||||
|
userid: 3, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "v-1", |
||||||
|
userid: 1, |
||||||
|
allowed: false, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "v-1", |
||||||
|
userid: 2, |
||||||
|
allowed: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "release", |
||||||
|
userid: 1, |
||||||
|
allowed: false, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
t.Run("Glob", func(t *testing.T) { |
||||||
|
protectedTags := []*ProtectedTag{ |
||||||
|
{ |
||||||
|
NamePattern: `*gitea`, |
||||||
|
AllowlistUserIDs: []int64{1}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
NamePattern: `v-*`, |
||||||
|
AllowlistUserIDs: []int64{2}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
NamePattern: "release", |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for n, c := range cases { |
||||||
|
isAllowed, err := IsUserAllowedToControlTag(protectedTags, c.name, c.userid) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("Regex", func(t *testing.T) { |
||||||
|
protectedTags := []*ProtectedTag{ |
||||||
|
{ |
||||||
|
NamePattern: `/gitea\z/`, |
||||||
|
AllowlistUserIDs: []int64{1}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
NamePattern: `/\Av-/`, |
||||||
|
AllowlistUserIDs: []int64{2}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
NamePattern: "/release/", |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for n, c := range cases { |
||||||
|
isAllowed, err := IsUserAllowedToControlTag(protectedTags, c.name, c.userid) |
||||||
|
assert.NoError(t, err) |
||||||
|
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package validation |
||||||
|
|
||||||
|
import ( |
||||||
|
"regexp" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"gitea.com/go-chi/binding" |
||||||
|
) |
||||||
|
|
||||||
|
func getRegexPatternErrorString(pattern string) string { |
||||||
|
if _, err := regexp.Compile(pattern); err != nil { |
||||||
|
return err.Error() |
||||||
|
} |
||||||
|
return "" |
||||||
|
} |
||||||
|
|
||||||
|
var regexValidationTestCases = []validationTestCase{ |
||||||
|
{ |
||||||
|
description: "Empty regex pattern", |
||||||
|
data: TestForm{ |
||||||
|
RegexPattern: "", |
||||||
|
}, |
||||||
|
expectedErrors: binding.Errors{}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
description: "Valid regex", |
||||||
|
data: TestForm{ |
||||||
|
RegexPattern: `(\d{1,3})+`, |
||||||
|
}, |
||||||
|
expectedErrors: binding.Errors{}, |
||||||
|
}, |
||||||
|
|
||||||
|
{ |
||||||
|
description: "Invalid regex", |
||||||
|
data: TestForm{ |
||||||
|
RegexPattern: "[a-", |
||||||
|
}, |
||||||
|
expectedErrors: binding.Errors{ |
||||||
|
binding.Error{ |
||||||
|
FieldNames: []string{"RegexPattern"}, |
||||||
|
Classification: ErrRegexPattern, |
||||||
|
Message: getRegexPatternErrorString("[a-"), |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
func Test_RegexPatternValidation(t *testing.T) { |
||||||
|
AddBindingRules() |
||||||
|
|
||||||
|
for _, testCase := range regexValidationTestCases { |
||||||
|
t.Run(testCase.description, func(t *testing.T) { |
||||||
|
performValidationTest(t, testCase) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,182 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package repo |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/models" |
||||||
|
"code.gitea.io/gitea/modules/base" |
||||||
|
"code.gitea.io/gitea/modules/context" |
||||||
|
"code.gitea.io/gitea/modules/setting" |
||||||
|
"code.gitea.io/gitea/modules/web" |
||||||
|
"code.gitea.io/gitea/services/forms" |
||||||
|
) |
||||||
|
|
||||||
|
// Tags render the page to protect tags
|
||||||
|
func Tags(ctx *context.Context) { |
||||||
|
if setTagsContext(ctx) != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplTags) |
||||||
|
} |
||||||
|
|
||||||
|
// NewProtectedTagPost handles creation of a protect tag
|
||||||
|
func NewProtectedTagPost(ctx *context.Context) { |
||||||
|
if setTagsContext(ctx) != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if ctx.HasError() { |
||||||
|
ctx.HTML(http.StatusOK, tplTags) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
repo := ctx.Repo.Repository |
||||||
|
form := web.GetForm(ctx).(*forms.ProtectTagForm) |
||||||
|
|
||||||
|
pt := &models.ProtectedTag{ |
||||||
|
RepoID: repo.ID, |
||||||
|
NamePattern: strings.TrimSpace(form.NamePattern), |
||||||
|
} |
||||||
|
|
||||||
|
if strings.TrimSpace(form.AllowlistUsers) != "" { |
||||||
|
pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) |
||||||
|
} |
||||||
|
if strings.TrimSpace(form.AllowlistTeams) != "" { |
||||||
|
pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) |
||||||
|
} |
||||||
|
|
||||||
|
if err := models.InsertProtectedTag(pt); err != nil { |
||||||
|
ctx.ServerError("InsertProtectedTag", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) |
||||||
|
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.Path) |
||||||
|
} |
||||||
|
|
||||||
|
// EditProtectedTag render the page to edit a protect tag
|
||||||
|
func EditProtectedTag(ctx *context.Context) { |
||||||
|
if setTagsContext(ctx) != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Data["PageIsEditProtectedTag"] = true |
||||||
|
|
||||||
|
pt := selectProtectedTagByContext(ctx) |
||||||
|
if pt == nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Data["name_pattern"] = pt.NamePattern |
||||||
|
ctx.Data["allowlist_users"] = strings.Join(base.Int64sToStrings(pt.AllowlistUserIDs), ",") |
||||||
|
ctx.Data["allowlist_teams"] = strings.Join(base.Int64sToStrings(pt.AllowlistTeamIDs), ",") |
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplTags) |
||||||
|
} |
||||||
|
|
||||||
|
// EditProtectedTagPost handles creation of a protect tag
|
||||||
|
func EditProtectedTagPost(ctx *context.Context) { |
||||||
|
if setTagsContext(ctx) != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Data["PageIsEditProtectedTag"] = true |
||||||
|
|
||||||
|
if ctx.HasError() { |
||||||
|
ctx.HTML(http.StatusOK, tplTags) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
pt := selectProtectedTagByContext(ctx) |
||||||
|
if pt == nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*forms.ProtectTagForm) |
||||||
|
|
||||||
|
pt.NamePattern = strings.TrimSpace(form.NamePattern) |
||||||
|
pt.AllowlistUserIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistUsers, ",")) |
||||||
|
pt.AllowlistTeamIDs, _ = base.StringsToInt64s(strings.Split(form.AllowlistTeams, ",")) |
||||||
|
|
||||||
|
if err := models.UpdateProtectedTag(pt); err != nil { |
||||||
|
ctx.ServerError("UpdateProtectedTag", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) |
||||||
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") |
||||||
|
} |
||||||
|
|
||||||
|
// DeleteProtectedTagPost handles deletion of a protected tag
|
||||||
|
func DeleteProtectedTagPost(ctx *context.Context) { |
||||||
|
pt := selectProtectedTagByContext(ctx) |
||||||
|
if pt == nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := models.DeleteProtectedTag(pt); err != nil { |
||||||
|
ctx.ServerError("DeleteProtectedTag", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) |
||||||
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") |
||||||
|
} |
||||||
|
|
||||||
|
func setTagsContext(ctx *context.Context) error { |
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.settings") |
||||||
|
ctx.Data["PageIsSettingsTags"] = true |
||||||
|
|
||||||
|
protectedTags, err := ctx.Repo.Repository.GetProtectedTags() |
||||||
|
if err != nil { |
||||||
|
ctx.ServerError("GetProtectedTags", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
ctx.Data["ProtectedTags"] = protectedTags |
||||||
|
|
||||||
|
users, err := ctx.Repo.Repository.GetReaders() |
||||||
|
if err != nil { |
||||||
|
ctx.ServerError("Repo.Repository.GetReaders", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
ctx.Data["Users"] = users |
||||||
|
|
||||||
|
if ctx.Repo.Owner.IsOrganization() { |
||||||
|
teams, err := ctx.Repo.Owner.TeamsWithAccessToRepo(ctx.Repo.Repository.ID, models.AccessModeRead) |
||||||
|
if err != nil { |
||||||
|
ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) |
||||||
|
return err |
||||||
|
} |
||||||
|
ctx.Data["Teams"] = teams |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func selectProtectedTagByContext(ctx *context.Context) *models.ProtectedTag { |
||||||
|
id := ctx.QueryInt64("id") |
||||||
|
if id == 0 { |
||||||
|
id = ctx.ParamsInt64(":id") |
||||||
|
} |
||||||
|
|
||||||
|
tag, err := models.GetProtectedTagByID(id) |
||||||
|
if err != nil { |
||||||
|
ctx.ServerError("GetProtectedTagByID", err) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
if tag != nil && tag.RepoID == ctx.Repo.Repository.ID { |
||||||
|
return tag |
||||||
|
} |
||||||
|
|
||||||
|
ctx.NotFound("", fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository)) |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package forms |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/context" |
||||||
|
"code.gitea.io/gitea/modules/web/middleware" |
||||||
|
|
||||||
|
"gitea.com/go-chi/binding" |
||||||
|
) |
||||||
|
|
||||||
|
// ProtectTagForm form for changing protected tag settings
|
||||||
|
type ProtectTagForm struct { |
||||||
|
NamePattern string `binding:"Required;GlobOrRegexPattern"` |
||||||
|
AllowlistUsers string |
||||||
|
AllowlistTeams string |
||||||
|
} |
||||||
|
|
||||||
|
// Validate validates the fields
|
||||||
|
func (f *ProtectTagForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { |
||||||
|
ctx := context.GetContext(req) |
||||||
|
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) |
||||||
|
} |
@ -0,0 +1,132 @@ |
|||||||
|
{{template "base/head" .}} |
||||||
|
<div class="page-content repository settings edit"> |
||||||
|
{{template "repo/header" .}} |
||||||
|
{{template "repo/settings/navbar" .}} |
||||||
|
<div class="ui container"> |
||||||
|
{{template "base/alert" .}} |
||||||
|
{{if .Repository.IsArchived}} |
||||||
|
<div class="ui warning message"> |
||||||
|
{{.i18n.Tr "repo.settings.archive.tagsettings_unavailable"}} |
||||||
|
</div> |
||||||
|
{{else}} |
||||||
|
<h4 class="ui top attached header"> |
||||||
|
{{.i18n.Tr "repo.settings.tags.protection"}} |
||||||
|
</h4> |
||||||
|
|
||||||
|
<div class="ui attached segment"> |
||||||
|
<div class="ui grid"> |
||||||
|
<div class="eight wide column"> |
||||||
|
<div class="ui segment"> |
||||||
|
<form class="ui form" action="{{.Link}}" method="post"> |
||||||
|
{{.CsrfTokenHtml}} |
||||||
|
<div class="field"> |
||||||
|
<label>{{.i18n.Tr "repo.settings.tags.protection.pattern"}}</label> |
||||||
|
<div id="search-tag-box" class="ui search"> |
||||||
|
<div class="ui input"> |
||||||
|
<input class="prompt" name="name_pattern" autocomplete="off" value="{{.name_pattern}}" placeholder="v*" autofocus required> |
||||||
|
</div> |
||||||
|
<div class="help">{{.i18n.Tr "repo.settings.tags.protection.pattern.description" | Safe}}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="whitelist field"> |
||||||
|
<label>{{.i18n.Tr "repo.settings.tags.protection.allowed.users"}}</label> |
||||||
|
<div class="ui multiple search selection dropdown"> |
||||||
|
<input type="hidden" name="allowlist_users" value="{{.allowlist_users}}"> |
||||||
|
<div class="default text">{{.i18n.Tr "repo.settings.protect_whitelist_search_users"}}</div> |
||||||
|
<div class="menu"> |
||||||
|
{{range .Users}} |
||||||
|
<div class="item" data-value="{{.ID}}"> |
||||||
|
{{avatar . 28 "mini"}} |
||||||
|
{{.GetDisplayName}} |
||||||
|
</div> |
||||||
|
{{end}} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{{if .Owner.IsOrganization}} |
||||||
|
<div class="whitelist field"> |
||||||
|
<label>{{.i18n.Tr "repo.settings.tags.protection.allowed.teams"}}</label> |
||||||
|
<div class="ui multiple search selection dropdown"> |
||||||
|
<input type="hidden" name="allowlist_teams" value="{{.allowlist_teams}}"> |
||||||
|
<div class="default text">{{.i18n.Tr "repo.settings.protect_whitelist_search_teams"}}</div> |
||||||
|
<div class="menu"> |
||||||
|
{{range .Teams}} |
||||||
|
<div class="item" data-value="{{.ID}}"> |
||||||
|
{{svg "octicon-people"}} |
||||||
|
{{.Name}} |
||||||
|
</div> |
||||||
|
{{end}} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{{end}} |
||||||
|
<div class="field"> |
||||||
|
{{if .PageIsEditProtectedTag}} |
||||||
|
<button class="ui green button"> |
||||||
|
{{$.i18n.Tr "save"}} |
||||||
|
</button> |
||||||
|
<a class="ui blue button" href="{{$.RepoLink}}/settings/tags"> |
||||||
|
{{$.i18n.Tr "cancel"}} |
||||||
|
</a> |
||||||
|
{{else}} |
||||||
|
<button class="ui green button"> |
||||||
|
{{$.i18n.Tr "repo.settings.tags.protection.create"}} |
||||||
|
</button> |
||||||
|
{{end}} |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="sixteen wide column"> |
||||||
|
<table class="ui single line table"> |
||||||
|
<thead> |
||||||
|
<th>{{.i18n.Tr "repo.settings.tags.protection.pattern"}}</th> |
||||||
|
<th>{{.i18n.Tr "repo.settings.tags.protection.allowed"}}</th> |
||||||
|
<th></th> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{{range .ProtectedTags}} |
||||||
|
<tr> |
||||||
|
<td><pre>{{.NamePattern}}</pre></td> |
||||||
|
<td> |
||||||
|
{{if or .AllowlistUserIDs (and $.Owner.IsOrganization .AllowlistTeamIDs)}} |
||||||
|
{{$userIDs := .AllowlistUserIDs}} |
||||||
|
{{range $.Users}} |
||||||
|
{{if contain $userIDs .ID }} |
||||||
|
<a class="ui basic image label" href="{{.HomeLink}}">{{avatar . 26}} {{.GetDisplayName}}</a> |
||||||
|
{{end}} |
||||||
|
{{end}} |
||||||
|
{{if $.Owner.IsOrganization}} |
||||||
|
{{$teamIDs := .AllowlistTeamIDs}} |
||||||
|
{{range $.Teams}} |
||||||
|
{{if contain $teamIDs .ID }} |
||||||
|
<a class="ui basic image label" href="{{$.Owner.OrganisationLink}}/teams/{{.LowerName}}">{{.Name}}</a> |
||||||
|
{{end}} |
||||||
|
{{end}} |
||||||
|
{{end}} |
||||||
|
{{else}} |
||||||
|
{{$.i18n.Tr "repo.settings.tags.protection.allowed.noone"}} |
||||||
|
{{end}} |
||||||
|
</td> |
||||||
|
<td class="right aligned"> |
||||||
|
<a class="ui tiny blue button" href="{{$.RepoLink}}/settings/tags/{{.ID}}">{{$.i18n.Tr "edit"}}</a> |
||||||
|
<form class="dib" action="{{$.RepoLink}}/settings/tags/delete" method="post"> |
||||||
|
{{$.CsrfTokenHtml}} |
||||||
|
<input type="hidden" name="id" value="{{.ID}}" /> |
||||||
|
<button class="ui tiny red button">{{$.i18n.Tr "remove"}}</button> |
||||||
|
</form> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{{else}} |
||||||
|
<tr class="center aligned"><td colspan="3">{{.i18n.Tr "repo.settings.tags.protection.none"}}</td></tr> |
||||||
|
{{end}} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{{end}} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{{template "base/footer" .}} |
Loading…
Reference in new issue