Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: custom repo buttons #15532

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ var migrations = []Migration{
NewMigration("Add LFS columns to Mirror", addLFSMirrorColumns),
// v179 -> v180
NewMigration("Convert avatar url to text", convertAvatarURLToText),
// v180 -> v181
NewMigration("add custom_repo_buttons_config column for repository table", addCustomRepoButtonsConfigRepositoryColumn),
}

// GetCurrentDBVersion returns the current db version
Expand Down
22 changes: 22 additions & 0 deletions models/migrations/v180.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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 (
"fmt"

"xorm.io/xorm"
)

func addCustomRepoButtonsConfigRepositoryColumn(x *xorm.Engine) error {
type Repository struct {
CustomRepoButtonsConfig string `xorm:"TEXT"`
}

if err := x.Sync2(new(Repository)); err != nil {
return fmt.Errorf("sync2: %v", err)
}
return nil
}
87 changes: 87 additions & 0 deletions models/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"gopkg.in/yaml.v2"

"xorm.io/builder"
)
Expand Down Expand Up @@ -246,6 +247,9 @@ type Repository struct {
// Avatar: ID(10-20)-md5(32) - must fit into 64 symbols
Avatar string `xorm:"VARCHAR(64)"`

CustomRepoButtonsConfig string `xorm:"TEXT"`
CustomRepoButtons []CustomRepoButton `xorm:"-"`

CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
Expand Down Expand Up @@ -2117,3 +2121,86 @@ func IterateRepository(f func(repo *Repository) error) error {
}
}
}

// CustomRepoButtonType type of custom repo button
type CustomRepoButtonType string

const (
// CustomRepoButtonTypeLink a single link (default)
CustomRepoButtonTypeLink CustomRepoButtonType = "link"
// CustomRepoButtonTypeContent some content with markdown format
CustomRepoButtonTypeContent = "content"
// CustomRepoButtonExample examle config
CustomRepoButtonExample string = `-
title: Sponsor
type: link
link: http://www.example.com

-
title: Sponsor 2
type: content
content: "## test content \n - [xx](http://www.example.com)"
`
)

// CustomRepoButton a config of CustomRepoButton
type CustomRepoButton struct {
Title string `yaml:"title"` // max length: 20
Typ CustomRepoButtonType `yaml:"type"`
a1012112796 marked this conversation as resolved.
Show resolved Hide resolved
Link string `yaml:"link"`
Content string `yaml:"content"`
RenderedContent string `yaml:"-"`
}

// IsLink check if it's a link button
func (b CustomRepoButton) IsLink() bool {
return b.Typ != CustomRepoButtonTypeContent
}

// LoadCustomRepoButton by config
func (repo *Repository) LoadCustomRepoButton() error {
if repo.CustomRepoButtons != nil {
return nil
}

repo.CustomRepoButtons = make([]CustomRepoButton, 0, 3)
err := yaml.Unmarshal([]byte(repo.CustomRepoButtonsConfig), &repo.CustomRepoButtons)
if err != nil {
return err
}

return nil
}

// CustomRepoButtonConfigVaild format check
func CustomRepoButtonConfigVaild(cfg string) (bool, error) {
btns := make([]CustomRepoButton, 0, 3)

err := yaml.Unmarshal([]byte(cfg), &btns)
if err != nil {
return false, err
}

// max button nums: 3
if len(btns) > 3 {
return false, nil
}

for _, btn := range btns {
if len(btn.Title) > 20 {
return false, nil
}
if btn.Typ != CustomRepoButtonTypeContent && len(btn.Link) == 0 {
return false, nil
}
}

return true, nil
}

// SetCustomRepoButtons sets custom button config
func (repo *Repository) SetCustomRepoButtons(cfg string) (err error) {
repo.CustomRepoButtonsConfig = cfg
_, err = x.Where("id = ?", repo.ID).Cols("custom_repo_buttons_config").NoAutoTime().Update(repo)
return
}
93 changes: 93 additions & 0 deletions models/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,96 @@ func TestRepoGetReviewerTeams(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 2, len(teams))
}

func TestRepo_LoadCustomRepoButton(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)

repo1.CustomRepoButtonsConfig = CustomRepoButtonExample

assert.NoError(t, repo1.LoadCustomRepoButton())
}

func TestCustomRepoButtonConfigVaild(t *testing.T) {
tests := []struct {
name string
cfg string
want bool
wantErr bool
}{
// empty
{
name: "empty",
cfg: "",
want: true,
wantErr: false,
},
// right config
{
name: "right config",
cfg: CustomRepoButtonExample,
want: true,
wantErr: false,
},
// missing link
{
name: "missing link",
cfg: `-
title: Sponsor
type: link
`,
want: false,
wantErr: false,
},
// too many buttons
{
name: "too many buttons",
cfg: `-
title: Sponsor
type: link
link: http://www.example.com

-
title: Sponsor
type: link
link: http://www.example.com

-
title: Sponsor
type: link
link: http://www.example.com

-
title: Sponsor
type: link
link: http://www.example.com
`,
want: false,
wantErr: false,
},
// too long title
{
name: "too long title",
cfg: `-
title: Sponsor-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
type: link
link: http://www.example.com
`,
want: false,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CustomRepoButtonConfigVaild(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("CustomRepoButtonConfigVaild() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("CustomRepoButtonConfigVaild() = %v, want %v", got, tt.want)
}
})
}
}
13 changes: 13 additions & 0 deletions modules/context/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,19 @@ func repoAssignment(ctx *Context, repo *models.Repository) {
ctx.Repo.Repository = repo
ctx.Data["RepoName"] = ctx.Repo.Repository.Name
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty

// load custom repo buttons
if err := ctx.Repo.Repository.LoadCustomRepoButton(); err != nil {
ctx.ServerError("LoadCustomRepoButton", err)
return
}

for index, btn := range repo.CustomRepoButtons {
if !btn.IsLink() {
repo.CustomRepoButtons[index].RenderedContent = string(markdown.Render([]byte(btn.Content), ctx.Repo.RepoLink,
ctx.Repo.Repository.ComposeMetas()))
}
}
}

// RepoIDAssignment returns a handler which assigns the repo to the context.
Expand Down
6 changes: 6 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1859,6 +1859,12 @@ settings.lfs_pointers.exists=Exists in store
settings.lfs_pointers.accessible=Accessible to User
settings.lfs_pointers.associateAccessible=Associate accessible %d OIDs

custom_repo_buttons_cfg_desc = configuration
settings.custom_repo_buttons = Custom repo buttons
settings.custom_repo_buttons.wrong_setting = Wrong custom repo buttons config
settings.custom_repo_buttons.error = An error occurred while trying to set custom repo buttons for the repo. See the log for more details.
settings.custom_repo_buttons.success = custom repo buttons was successfully seted.

diff.browse_source = Browse Source
diff.parent = parent
diff.commit = commit
Expand Down
18 changes: 17 additions & 1 deletion routers/repo/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ func Settings(ctx *context.Context) {
ctx.Data["SigningKeyAvailable"] = len(signing) > 0
ctx.Data["SigningSettings"] = setting.Repository.Signing

ctx.Data["CustomRepoButtonExample"] = models.CustomRepoButtonExample

ctx.HTML(http.StatusOK, tplSettingsOptions)
}

Expand Down Expand Up @@ -612,7 +614,21 @@ func SettingsPost(ctx *context.Context) {

log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
ctx.Redirect(ctx.Repo.RepoLink + "/settings")

case "custom_repo_buttons":
if ok, _ := models.CustomRepoButtonConfigVaild(form.CustomRepoButtonsCfg); !ok {
ctx.Flash.Error(ctx.Tr("repo.settings.custom_repo_buttons.wrong_setting"))
ctx.Data["Err_CustomRepoButtons"] = true
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
return
}
if err := repo.SetCustomRepoButtons(form.CustomRepoButtonsCfg); err != nil {
log.Error("repo.SetCustomRepoButtons: %s", err)
ctx.Flash.Error(ctx.Tr("repo.settings.custom_repo_buttons.error"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.custom_repo_buttons.success"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings")
default:
ctx.NotFound("", nil)
}
Expand Down
3 changes: 3 additions & 0 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ type RepoSettingForm struct {

// Admin settings
EnableHealthCheck bool

// custom repo buttons
CustomRepoButtonsCfg string
}

// Validate validates the fields
Expand Down
25 changes: 25 additions & 0 deletions templates/repo/header.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@
</div>
{{if not .IsBeingCreated}}
<div class="repo-buttons">
{{range .CustomRepoButtons}}
<div class="ui labeled button">
{{if .IsLink}}
<a class="ui basic label" href="{{.Link}}" target="_blank" rel="noopener noreferrer">
{{.Title}}
</a>
{{else}}
<a class="ui basic label show-repo-button-content"
data-title="{{.Title}}"
data-content="{{.RenderedContent}}">
{{.Title}}
</a>
{{end}}
</div>
{{end}}
{{if $.RepoTransfer}}
<form method="post" action="{{$.RepoLink}}/action/accept_transfer?redirect_to={{$.RepoLink}}">
{{$.CsrfTokenHtml}}
Expand Down Expand Up @@ -98,6 +113,16 @@
{{end}}
</div><!-- end grid -->
</div><!-- end container -->

<div class="ui modal custom-repo-buttons" id="detail-modal">
{{svg "octicon-x" 16 "close inside"}}
<div class="content">
<div class="sub header"></div>
<div class="ui divider"></div>
<div class="render-content markdown"></div>
</div>
</div>

{{end}}
<div class="ui tabs container">
{{if not .Repository.IsBeingCreated}}
Expand Down
18 changes: 18 additions & 0 deletions templates/repo/settings/options.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,24 @@
</div>
{{end}}

<h4 class="ui top attached header">
{{.i18n.Tr "repo.settings.custom_repo_buttons"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="action" value="custom_repo_buttons">
<div class="field {{if .Err_CustomRepoButtons}}error{{end}}">
<label for="custom_repo_buttons_cfg">{{$.i18n.Tr "repo.custom_repo_buttons_cfg_desc"}}</label>
<textarea id="custom_repo_buttons_cfg" name="custom_repo_buttons_cfg" rows="4" placeholder="{{$.CustomRepoButtonExample}}">{{.Repository.CustomRepoButtonsConfig}}</textarea>
</div>
<div class="ui divider"></div>
<div class="field">
<button class="ui green button">{{$.i18n.Tr "repo.settings.update_settings"}}</button>
</div>
</form>
</div>

{{if .Permission.IsOwner}}
<h4 class="ui top attached error header">
{{.i18n.Tr "repo.settings.danger_zone"}}
Expand Down
16 changes: 16 additions & 0 deletions web_src/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,22 @@ async function initRepository() {
$('.language-stats-details, .repository-menu').slideToggle();
});
}

// custom repo buttons
(function() {
if ($('.repo-buttons').length === 0) {
return;
}

const $detailModal = $('#detail-modal');

$('.show-repo-button-content').on('click', function () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be a good case for @silverwind's jsx PR, as I am concerned with the below line of injecting HTML and it creating an XSS issue.

Copy link
Member

@silverwind silverwind Apr 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not suitable. JSX is when you want to create a DOM structure completely in JS but in this case, it's some pre-rendered Markdown from the server. Generally I don't like putting HTML into data attributes, maybe it could be rendered directly but hidden via CSS, thought if this is just a dialog, that may already work without any JS involved.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@silverwind How about current way? Thanks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better, thanks.

$detailModal.find('.content .render-content').html($(this).data('content'));
$detailModal.find('.sub.header').text($(this).data('title'));
$detailModal.modal('show');
return false;
});
})();
}

function initPullRequestMergeInstruction() {
Expand Down