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>
This commit is contained in:
parent
7a0ed9a046
commit
44b8b07631
27 changed files with 1227 additions and 189 deletions
|
@ -221,8 +221,8 @@ Gitea or set your environment appropriately.`, "")
|
|||
total++
|
||||
lastline++
|
||||
|
||||
// If the ref is a branch, check if it's protected
|
||||
if strings.HasPrefix(refFullName, git.BranchPrefix) {
|
||||
// If the ref is a branch or tag, check if it's protected
|
||||
if strings.HasPrefix(refFullName, git.BranchPrefix) || strings.HasPrefix(refFullName, git.TagPrefix) {
|
||||
oldCommitIDs[count] = oldCommitID
|
||||
newCommitIDs[count] = newCommitID
|
||||
refFullNames[count] = refFullName
|
||||
|
@ -230,7 +230,7 @@ Gitea or set your environment appropriately.`, "")
|
|||
fmt.Fprintf(out, "*")
|
||||
|
||||
if count >= hookBatchSize {
|
||||
fmt.Fprintf(out, " Checking %d branches\n", count)
|
||||
fmt.Fprintf(out, " Checking %d references\n", count)
|
||||
|
||||
hookOptions.OldCommitIDs = oldCommitIDs
|
||||
hookOptions.NewCommitIDs = newCommitIDs
|
||||
|
@ -261,7 +261,7 @@ Gitea or set your environment appropriately.`, "")
|
|||
hookOptions.NewCommitIDs = newCommitIDs[:count]
|
||||
hookOptions.RefFullNames = refFullNames[:count]
|
||||
|
||||
fmt.Fprintf(out, " Checking %d branches\n", count)
|
||||
fmt.Fprintf(out, " Checking %d references\n", count)
|
||||
|
||||
statusCode, msg := private.HookPreReceive(username, reponame, hookOptions)
|
||||
switch statusCode {
|
||||
|
|
57
docs/content/doc/advanced/protected-tags.en-us.md
Normal file
57
docs/content/doc/advanced/protected-tags.en-us.md
Normal file
|
@ -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 |
|
|
@ -59,7 +59,9 @@ func TestMirrorPull(t *testing.T) {
|
|||
|
||||
assert.NoError(t, release_service.CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.2",
|
||||
Target: "master",
|
||||
Title: "v0.2 is released",
|
||||
|
|
74
integrations/repo_tag_test.go
Normal file
74
integrations/repo_tag_test.go
Normal file
|
@ -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")
|
||||
})
|
||||
})
|
||||
}
|
|
@ -985,6 +985,21 @@ func (err ErrInvalidTagName) Error() string {
|
|||
return fmt.Sprintf("release tag name is not valid [tag_name: %s]", err.TagName)
|
||||
}
|
||||
|
||||
// ErrProtectedTagName represents a "ProtectedTagName" kind of error.
|
||||
type ErrProtectedTagName struct {
|
||||
TagName string
|
||||
}
|
||||
|
||||
// IsErrProtectedTagName checks if an error is a ErrProtectedTagName.
|
||||
func IsErrProtectedTagName(err error) bool {
|
||||
_, ok := err.(ErrProtectedTagName)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrProtectedTagName) Error() string {
|
||||
return fmt.Sprintf("release tag name is protected [tag_name: %s]", err.TagName)
|
||||
}
|
||||
|
||||
// ErrRepoFileAlreadyExists represents a "RepoFileAlreadyExist" kind of error.
|
||||
type ErrRepoFileAlreadyExists struct {
|
||||
Path string
|
||||
|
|
|
@ -321,6 +321,8 @@ var migrations = []Migration{
|
|||
NewMigration("Rename Task errors to message", renameTaskErrorsToMessage),
|
||||
// v185 -> v186
|
||||
NewMigration("Add new table repo_archiver", addRepoArchiver),
|
||||
// v186 -> v187
|
||||
NewMigration("Create protected tag table", createProtectedTagTable),
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current db version
|
||||
|
|
26
models/migrations/v186.go
Normal file
26
models/migrations/v186.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -137,6 +137,7 @@ func init() {
|
|||
new(IssueIndex),
|
||||
new(PushMirror),
|
||||
new(RepoArchiver),
|
||||
new(ProtectedTag),
|
||||
)
|
||||
|
||||
gonicNames := []string{"SSL", "UID"}
|
||||
|
|
131
models/protected_tag.go
Normal file
131
models/protected_tag.go
Normal file
|
@ -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)
|
||||
}
|
162
models/protected_tag_test.go
Normal file
162
models/protected_tag_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1498,6 +1498,7 @@ func DeleteRepository(doer *User, uid, repoID int64) error {
|
|||
&Mirror{RepoID: repoID},
|
||||
&Notification{RepoID: repoID},
|
||||
&ProtectedBranch{RepoID: repoID},
|
||||
&ProtectedTag{RepoID: repoID},
|
||||
&PullRequest{BaseRepoID: repoID},
|
||||
&PushMirror{RepoID: repoID},
|
||||
&Release{RepoID: repoID},
|
||||
|
|
|
@ -19,6 +19,9 @@ const (
|
|||
|
||||
// ErrGlobPattern is returned when glob pattern is invalid
|
||||
ErrGlobPattern = "GlobPattern"
|
||||
|
||||
// ErrRegexPattern is returned when a regex pattern is invalid
|
||||
ErrRegexPattern = "RegexPattern"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -53,6 +56,8 @@ func AddBindingRules() {
|
|||
addGitRefNameBindingRule()
|
||||
addValidURLBindingRule()
|
||||
addGlobPatternRule()
|
||||
addRegexPatternRule()
|
||||
addGlobOrRegexPatternRule()
|
||||
}
|
||||
|
||||
func addGitRefNameBindingRule() {
|
||||
|
@ -102,7 +107,11 @@ func addGlobPatternRule() {
|
|||
IsMatch: func(rule string) bool {
|
||||
return rule == "GlobPattern"
|
||||
},
|
||||
IsValid: func(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
|
||||
IsValid: globPatternValidator,
|
||||
})
|
||||
}
|
||||
|
||||
func globPatternValidator(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
|
||||
str := fmt.Sprintf("%v", val)
|
||||
|
||||
if len(str) != 0 {
|
||||
|
@ -113,6 +122,40 @@ func addGlobPatternRule() {
|
|||
}
|
||||
|
||||
return true, errs
|
||||
}
|
||||
|
||||
func addRegexPatternRule() {
|
||||
binding.AddRule(&binding.Rule{
|
||||
IsMatch: func(rule string) bool {
|
||||
return rule == "RegexPattern"
|
||||
},
|
||||
IsValid: regexPatternValidator,
|
||||
})
|
||||
}
|
||||
|
||||
func regexPatternValidator(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
|
||||
str := fmt.Sprintf("%v", val)
|
||||
|
||||
if _, err := regexp.Compile(str); err != nil {
|
||||
errs.Add([]string{name}, ErrRegexPattern, err.Error())
|
||||
return false, errs
|
||||
}
|
||||
|
||||
return true, errs
|
||||
}
|
||||
|
||||
func addGlobOrRegexPatternRule() {
|
||||
binding.AddRule(&binding.Rule{
|
||||
IsMatch: func(rule string) bool {
|
||||
return rule == "GlobOrRegexPattern"
|
||||
},
|
||||
IsValid: func(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
|
||||
str := strings.TrimSpace(fmt.Sprintf("%v", val))
|
||||
|
||||
if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") {
|
||||
return regexPatternValidator(errs, name, str[1:len(str)-1])
|
||||
}
|
||||
return globPatternValidator(errs, name, val)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ type (
|
|||
BranchName string `form:"BranchName" binding:"GitRefName"`
|
||||
URL string `form:"ValidUrl" binding:"ValidUrl"`
|
||||
GlobPattern string `form:"GlobPattern" binding:"GlobPattern"`
|
||||
RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
60
modules/validation/regex_pattern_test.go
Normal file
60
modules/validation/regex_pattern_test.go
Normal file
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -135,6 +135,8 @@ func Validate(errs binding.Errors, data map[string]interface{}, f Form, l transl
|
|||
data["ErrorMsg"] = trName + l.Tr("form.include_error", GetInclude(field))
|
||||
case validation.ErrGlobPattern:
|
||||
data["ErrorMsg"] = trName + l.Tr("form.glob_pattern_error", errs[0].Message)
|
||||
case validation.ErrRegexPattern:
|
||||
data["ErrorMsg"] = trName + l.Tr("form.regex_pattern_error", errs[0].Message)
|
||||
default:
|
||||
data["ErrorMsg"] = l.Tr("form.unknown_error") + " " + errs[0].Classification
|
||||
}
|
||||
|
|
|
@ -83,6 +83,7 @@ add = Add
|
|||
add_all = Add All
|
||||
remove = Remove
|
||||
remove_all = Remove All
|
||||
edit = Edit
|
||||
|
||||
write = Write
|
||||
preview = Preview
|
||||
|
@ -415,6 +416,7 @@ email_error = ` is not a valid email address.`
|
|||
url_error = ` is not a valid URL.`
|
||||
include_error = ` must contain substring '%s'.`
|
||||
glob_pattern_error = ` glob pattern is invalid: %s.`
|
||||
regex_pattern_error = ` regex pattern is invalid: %s.`
|
||||
unknown_error = Unknown error:
|
||||
captcha_incorrect = The CAPTCHA code is incorrect.
|
||||
password_not_match = The passwords do not match.
|
||||
|
@ -1802,7 +1804,7 @@ settings.event_pull_request_review_desc = Pull request approved, rejected, or re
|
|||
settings.event_pull_request_sync = Pull Request Synchronized
|
||||
settings.event_pull_request_sync_desc = Pull request synchronized.
|
||||
settings.branch_filter = Branch filter
|
||||
settings.branch_filter_desc = Branch whitelist for push, branch creation and branch deletion events, specified as glob pattern. If empty or <code>*</code>, events for all branches are reported. See <a href="https://godoc.org/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for syntax. Examples: <code>master</code>, <code>{master,release*}</code>.
|
||||
settings.branch_filter_desc = Branch whitelist for push, branch creation and branch deletion events, specified as glob pattern. If empty or <code>*</code>, events for all branches are reported. See <a href="https://pkg.go.dev/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for syntax. Examples: <code>master</code>, <code>{master,release*}</code>.
|
||||
settings.active = Active
|
||||
settings.active_helper = Information about triggered events will be sent to this webhook URL.
|
||||
settings.add_hook_success = The webhook has been added.
|
||||
|
@ -1872,7 +1874,7 @@ settings.dismiss_stale_approvals_desc = When new commits that change the content
|
|||
settings.require_signed_commits = Require Signed Commits
|
||||
settings.require_signed_commits_desc = Reject pushes to this branch if they are unsigned or unverifiable.
|
||||
settings.protect_protected_file_patterns = Protected file patterns (separated using semicolon '\;'):
|
||||
settings.protect_protected_file_patterns_desc = Protected files that are not allowed to be changed directly even if user has rights to add, edit, or delete files in this branch. Multiple patterns can be separated using semicolon ('\;'). See <a href="https://godoc.org/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
|
||||
settings.protect_protected_file_patterns_desc = Protected files that are not allowed to be changed directly even if user has rights to add, edit, or delete files in this branch. Multiple patterns can be separated using semicolon ('\;'). See <a href="https://pkg.go.dev/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
|
||||
settings.add_protected_branch = Enable protection
|
||||
settings.delete_protected_branch = Disable protection
|
||||
settings.update_protect_branch_success = Branch protection for branch '%s' has been updated.
|
||||
|
@ -1891,6 +1893,16 @@ settings.choose_branch = Choose a branch…
|
|||
settings.no_protected_branch = There are no protected branches.
|
||||
settings.edit_protected_branch = Edit
|
||||
settings.protected_branch_required_approvals_min = Required approvals cannot be negative.
|
||||
settings.tags = Tags
|
||||
settings.tags.protection = Tag Protection
|
||||
settings.tags.protection.pattern = Tag Pattern
|
||||
settings.tags.protection.allowed = Allowed
|
||||
settings.tags.protection.allowed.users = Allowed users
|
||||
settings.tags.protection.allowed.teams = Allowed teams
|
||||
settings.tags.protection.allowed.noone = No One
|
||||
settings.tags.protection.create = Protect Tag
|
||||
settings.tags.protection.none = There are no protected tags.
|
||||
settings.tags.protection.pattern.description = You can use a single name or a glob pattern or regular expression to match multiple tags. Read more in the <a target="_blank" rel="noopener" href="https://docs.gitea.io/en-us/protected-tags/">protected tags guide</a>.
|
||||
settings.bot_token = Bot Token
|
||||
settings.chat_id = Chat ID
|
||||
settings.matrix.homeserver_url = Homeserver URL
|
||||
|
@ -1904,6 +1916,7 @@ settings.archive.success = The repo was successfully archived.
|
|||
settings.archive.error = An error occurred while trying to archive the repo. See the log for more details.
|
||||
settings.archive.error_ismirror = You cannot archive a mirrored repo.
|
||||
settings.archive.branchsettings_unavailable = Branch settings are not available if the repo is archived.
|
||||
settings.archive.tagsettings_unavailable = Tag settings are not available if the repo is archived.
|
||||
settings.unarchive.button = Un-Archive Repo
|
||||
settings.unarchive.header = Un-Archive This Repo
|
||||
settings.unarchive.text = Un-Archiving the repo will restore its ability to receive commits and pushes, as well as new issues and pull-requests.
|
||||
|
@ -2018,6 +2031,7 @@ release.deletion_tag_desc = Will delete this tag from repository. Repository con
|
|||
release.deletion_tag_success = The tag has been deleted.
|
||||
release.tag_name_already_exist = A release with this tag name already exists.
|
||||
release.tag_name_invalid = The tag name is not valid.
|
||||
release.tag_name_protected = The tag name is protected.
|
||||
release.tag_already_exist = This tag name already exists.
|
||||
release.downloads = Downloads
|
||||
release.download_count = Downloads: %s
|
||||
|
|
|
@ -155,12 +155,22 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||
private.GitQuarantinePath+"="+opts.GitQuarantinePath)
|
||||
}
|
||||
|
||||
protectedTags, err := repo.GetProtectedTags()
|
||||
if err != nil {
|
||||
log.Error("Unable to get protected tags for %-v Error: %v", repo, err)
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Iterate across the provided old commit IDs
|
||||
for i := range opts.OldCommitIDs {
|
||||
oldCommitID := opts.OldCommitIDs[i]
|
||||
newCommitID := opts.NewCommitIDs[i]
|
||||
refFullName := opts.RefFullNames[i]
|
||||
|
||||
if strings.HasPrefix(refFullName, git.BranchPrefix) {
|
||||
branchName := strings.TrimPrefix(refFullName, git.BranchPrefix)
|
||||
if branchName == repo.DefaultBranch && newCommitID == git.EmptySHA {
|
||||
log.Warn("Forbidden: Branch: %s is the default branch in %-v and cannot be deleted", branchName, repo)
|
||||
|
@ -365,6 +375,29 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||
return
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(refFullName, git.TagPrefix) {
|
||||
tagName := strings.TrimPrefix(refFullName, git.TagPrefix)
|
||||
|
||||
isAllowed, err := models.IsUserAllowedToControlTag(protectedTags, tagName, opts.UserID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !isAllowed {
|
||||
log.Warn("Forbidden: Tag %s in %-v is protected", tagName, repo)
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
Err: fmt.Sprintf("Tag %s is protected", tagName),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Error("Unexpected ref: %s", refFullName)
|
||||
ctx.JSON(http.StatusInternalServerError, private.Response{
|
||||
Err: fmt.Sprintf("Unexpected ref: %s", refFullName),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, []byte("ok"))
|
||||
|
|
|
@ -322,6 +322,18 @@ func NewReleasePost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if models.IsErrInvalidTagName(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_invalid"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL())
|
||||
return
|
||||
}
|
||||
|
||||
if models.IsErrProtectedTagName(err) {
|
||||
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServerError("releaseservice.CreateNewTag", err)
|
||||
return
|
||||
}
|
||||
|
@ -333,7 +345,9 @@ func NewReleasePost(ctx *context.Context) {
|
|||
|
||||
rel = &models.Release{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Repo: ctx.Repo.Repository,
|
||||
PublisherID: ctx.User.ID,
|
||||
Publisher: ctx.User,
|
||||
Title: form.Title,
|
||||
TagName: form.TagName,
|
||||
Target: form.Target,
|
||||
|
@ -350,6 +364,8 @@ func NewReleasePost(ctx *context.Context) {
|
|||
ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_already_exist"), tplReleaseNew, &form)
|
||||
case models.IsErrInvalidTagName(err):
|
||||
ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form)
|
||||
case models.IsErrProtectedTagName(err):
|
||||
ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form)
|
||||
default:
|
||||
ctx.ServerError("CreateRelease", err)
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ const (
|
|||
tplSettingsOptions base.TplName = "repo/settings/options"
|
||||
tplCollaboration base.TplName = "repo/settings/collaboration"
|
||||
tplBranches base.TplName = "repo/settings/branches"
|
||||
tplTags base.TplName = "repo/settings/tags"
|
||||
tplGithooks base.TplName = "repo/settings/githooks"
|
||||
tplGithookEdit base.TplName = "repo/settings/githook_edit"
|
||||
tplDeployKeys base.TplName = "repo/settings/deploy_keys"
|
||||
|
|
182
routers/web/repo/tag.go
Normal file
182
routers/web/repo/tag.go
Normal file
|
@ -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
|
||||
}
|
|
@ -594,12 +594,21 @@ func RegisterRoutes(m *web.Route) {
|
|||
m.Post("/delete", repo.DeleteTeam)
|
||||
})
|
||||
})
|
||||
|
||||
m.Group("/branches", func() {
|
||||
m.Combo("").Get(repo.ProtectedBranch).Post(repo.ProtectedBranchPost)
|
||||
m.Combo("/*").Get(repo.SettingsProtectedBranch).
|
||||
Post(bindIgnErr(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo.SettingsProtectedBranchPost)
|
||||
}, repo.MustBeNotEmpty)
|
||||
|
||||
m.Group("/tags", func() {
|
||||
m.Get("", repo.Tags)
|
||||
m.Post("", bindIgnErr(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo.NewProtectedTagPost)
|
||||
m.Post("/delete", context.RepoMustNotBeArchived(), repo.DeleteProtectedTagPost)
|
||||
m.Get("/{id}", repo.EditProtectedTag)
|
||||
m.Post("/{id}", bindIgnErr(forms.ProtectTagForm{}), context.RepoMustNotBeArchived(), repo.EditProtectedTagPost)
|
||||
})
|
||||
|
||||
m.Group("/hooks/git", func() {
|
||||
m.Get("", repo.GitHooks)
|
||||
m.Combo("/{name}").Get(repo.GitHooksEdit).
|
||||
|
|
27
services/forms/repo_tag_form.go
Normal file
27
services/forms/repo_tag_form.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -23,6 +23,25 @@ func createTag(gitRepo *git.Repository, rel *models.Release, msg string) (bool,
|
|||
// Only actual create when publish.
|
||||
if !rel.IsDraft {
|
||||
if !gitRepo.IsTagExist(rel.TagName) {
|
||||
if err := rel.LoadAttributes(); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
protectedTags, err := rel.Repo.GetProtectedTags()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetProtectedTags: %v", err)
|
||||
}
|
||||
isAllowed, err := models.IsUserAllowedToControlTag(protectedTags, rel.TagName, rel.PublisherID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !isAllowed {
|
||||
return false, models.ErrProtectedTagName{
|
||||
TagName: rel.TagName,
|
||||
}
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetCommit(rel.Target)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetCommit: %v", err)
|
||||
|
@ -49,11 +68,7 @@ func createTag(gitRepo *git.Repository, rel *models.Release, msg string) (bool,
|
|||
}
|
||||
created = true
|
||||
rel.LowerTagName = strings.ToLower(rel.TagName)
|
||||
// Prepare Notify
|
||||
if err := rel.LoadAttributes(); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
notification.NotifyPushCommits(
|
||||
rel.Publisher, rel.Repo,
|
||||
&repository.PushUpdateOptions{
|
||||
|
@ -137,7 +152,9 @@ func CreateNewTag(doer *models.User, repo *models.Repository, commit, tagName, m
|
|||
|
||||
rel := &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: doer.ID,
|
||||
Publisher: doer,
|
||||
TagName: tagName,
|
||||
Target: commit,
|
||||
IsDraft: false,
|
||||
|
|
|
@ -33,7 +33,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1",
|
||||
Target: "master",
|
||||
Title: "v0.1 is released",
|
||||
|
@ -45,7 +47,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1.1",
|
||||
Target: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||
Title: "v0.1.1 is released",
|
||||
|
@ -57,7 +61,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1.2",
|
||||
Target: "65f1bf2",
|
||||
Title: "v0.1.2 is released",
|
||||
|
@ -69,7 +75,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1.3",
|
||||
Target: "65f1bf2",
|
||||
Title: "v0.1.3 is released",
|
||||
|
@ -81,7 +89,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1.4",
|
||||
Target: "65f1bf2",
|
||||
Title: "v0.1.4 is released",
|
||||
|
@ -99,7 +109,9 @@ func TestRelease_Create(t *testing.T) {
|
|||
|
||||
var release = models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v0.1.5",
|
||||
Target: "65f1bf2",
|
||||
Title: "v0.1.5 is released",
|
||||
|
@ -125,7 +137,9 @@ func TestRelease_Update(t *testing.T) {
|
|||
// Test a changed release
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v1.1.1",
|
||||
Target: "master",
|
||||
Title: "v1.1.1 is released",
|
||||
|
@ -147,7 +161,9 @@ func TestRelease_Update(t *testing.T) {
|
|||
// Test a changed draft
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v1.2.1",
|
||||
Target: "65f1bf2",
|
||||
Title: "v1.2.1 is draft",
|
||||
|
@ -169,7 +185,9 @@ func TestRelease_Update(t *testing.T) {
|
|||
// Test a changed pre-release
|
||||
assert.NoError(t, CreateRelease(gitRepo, &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v1.3.1",
|
||||
Target: "65f1bf2",
|
||||
Title: "v1.3.1 is pre-released",
|
||||
|
@ -192,7 +210,9 @@ func TestRelease_Update(t *testing.T) {
|
|||
// Test create release
|
||||
release = &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v1.1.2",
|
||||
Target: "master",
|
||||
Title: "v1.1.2 is released",
|
||||
|
@ -258,7 +278,9 @@ func TestRelease_createTag(t *testing.T) {
|
|||
// Test a changed release
|
||||
release := &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v2.1.1",
|
||||
Target: "master",
|
||||
Title: "v2.1.1 is released",
|
||||
|
@ -280,7 +302,9 @@ func TestRelease_createTag(t *testing.T) {
|
|||
// Test a changed draft
|
||||
release = &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v2.2.1",
|
||||
Target: "65f1bf2",
|
||||
Title: "v2.2.1 is draft",
|
||||
|
@ -301,7 +325,9 @@ func TestRelease_createTag(t *testing.T) {
|
|||
// Test a changed pre-release
|
||||
release = &models.Release{
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
PublisherID: user.ID,
|
||||
Publisher: user,
|
||||
TagName: "v2.3.1",
|
||||
Target: "65f1bf2",
|
||||
Title: "v2.3.1 is pre-released",
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<li {{if .PageIsSettingsOptions}}class="current"{{end}}><a href="{{.RepoLink}}/settings">{{.i18n.Tr "repo.settings.options"}}</a></li>
|
||||
<li {{if .PageIsSettingsCollaboration}}class="current"{{end}}><a href="{{.RepoLink}}/settings/collaboration">{{.i18n.Tr "repo.settings.collaboration"}}</a></li>
|
||||
<li {{if .PageIsSettingsBranches}}class="current"{{end}}><a href="{{.RepoLink}}/settings/branches">{{.i18n.Tr "repo.settings.branches"}}</a></li>
|
||||
<li {{if .PageIsSettingsTags}}class="current"{{end}}><a href="{{.RepoLink}}/settings/tags">{{.i18n.Tr "repo.settings.tags"}}</a></li>
|
||||
{{if not DisableWebhooks}}
|
||||
<li {{if .PageIsSettingsHooks}}class="current"{{end}}><a href="{{.RepoLink}}/settings/hooks">{{.i18n.Tr "repo.settings.hooks"}}</a></li>
|
||||
{{end}}
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
{{.i18n.Tr "repo.settings.branches"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsTags}}active{{end}} item" href="{{.RepoLink}}/settings/tags">
|
||||
{{.i18n.Tr "repo.settings.tags"}}
|
||||
</a>
|
||||
{{if not DisableWebhooks}}
|
||||
<a class="{{if .PageIsSettingsHooks}}active{{end}} item" href="{{.RepoLink}}/settings/hooks">
|
||||
{{.i18n.Tr "repo.settings.hooks"}}
|
||||
|
|
132
templates/repo/settings/tags.tmpl
Normal file
132
templates/repo/settings/tags.tmpl
Normal file
|
@ -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" .}}
|
Reference in a new issue