mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-14 06:39:40 +01:00
[BRANDING] define the forgejo webhook type
templates/swagger/v1_json.tmpl updated with `make generate-swagger` (cherry picked from commit88899c492e
) (cherry picked from commit7171bd9617
) (cherry picked from commit1a742446c1
) (cherry picked from commitd7c189d7b2
) Conflicts: routers/web/web.go (cherry picked from commitcbdea868e4
) (cherry picked from commit6cd150483b
) (cherry picked from commit47246da8d3
) (cherry picked from commitf2aa0e6b76
) (cherry picked from commit5a4fc69a16
) (cherry picked from commit48e444ca09
)
This commit is contained in:
parent
681d3ed5c4
commit
888e537811
14 changed files with 127 additions and 12 deletions
|
@ -35,7 +35,7 @@ func loadWebhookFrom(rootCfg ConfigProvider) {
|
||||||
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
|
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
|
||||||
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
|
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
|
||||||
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
|
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
|
||||||
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
|
Webhook.Types = []string{"forgejo", "gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
|
||||||
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
|
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
|
||||||
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
|
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
|
||||||
if Webhook.ProxyURL != "" {
|
if Webhook.ProxyURL != "" {
|
||||||
|
|
|
@ -40,7 +40,7 @@ type CreateHookOptionConfig map[string]string
|
||||||
// CreateHookOption options when create a hook
|
// CreateHookOption options when create a hook
|
||||||
type CreateHookOption struct {
|
type CreateHookOption struct {
|
||||||
// required: true
|
// required: true
|
||||||
// enum: dingtalk,discord,gitea,gogs,msteams,slack,telegram,feishu,wechatwork,packagist
|
// enum: forgejo,dingtalk,discord,gitea,gogs,msteams,slack,telegram,feishu,wechatwork,packagist
|
||||||
Type string `json:"type" binding:"Required"`
|
Type string `json:"type" binding:"Required"`
|
||||||
// required: true
|
// required: true
|
||||||
Config CreateHookOptionConfig `json:"config" binding:"Required"`
|
Config CreateHookOptionConfig `json:"config" binding:"Required"`
|
||||||
|
|
|
@ -71,6 +71,7 @@ type HookType = string
|
||||||
|
|
||||||
// Types of webhooks
|
// Types of webhooks
|
||||||
const (
|
const (
|
||||||
|
FORGEJO HookType = "forgejo"
|
||||||
GITEA HookType = "gitea"
|
GITEA HookType = "gitea"
|
||||||
GOGS HookType = "gogs"
|
GOGS HookType = "gogs"
|
||||||
SLACK HookType = "slack"
|
SLACK HookType = "slack"
|
||||||
|
|
|
@ -307,6 +307,34 @@ func editWebhook(ctx *context.Context, params webhookParams) {
|
||||||
ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
|
ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForgejoHooksNewPost response for creating Forgejo webhook
|
||||||
|
func ForgejoHooksNewPost(ctx *context.Context) {
|
||||||
|
createWebhook(ctx, forgejoHookParams(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgejoHooksEditPost response for editing Forgejo webhook
|
||||||
|
func ForgejoHooksEditPost(ctx *context.Context) {
|
||||||
|
editWebhook(ctx, forgejoHookParams(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgejoHookParams(ctx *context.Context) webhookParams {
|
||||||
|
form := web.GetForm(ctx).(*forms.NewWebhookForm)
|
||||||
|
|
||||||
|
contentType := webhook.ContentTypeJSON
|
||||||
|
if webhook.HookContentType(form.ContentType) == webhook.ContentTypeForm {
|
||||||
|
contentType = webhook.ContentTypeForm
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhookParams{
|
||||||
|
Type: webhook_module.FORGEJO,
|
||||||
|
URL: form.PayloadURL,
|
||||||
|
ContentType: contentType,
|
||||||
|
Secret: form.Secret,
|
||||||
|
HTTPMethod: form.HTTPMethod,
|
||||||
|
WebhookForm: form.WebhookForm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GiteaHooksNewPost response for creating Gitea webhook
|
// GiteaHooksNewPost response for creating Gitea webhook
|
||||||
func GiteaHooksNewPost(ctx *context.Context) {
|
func GiteaHooksNewPost(ctx *context.Context) {
|
||||||
createWebhook(ctx, giteaHookParams(ctx))
|
createWebhook(ctx, giteaHookParams(ctx))
|
||||||
|
|
|
@ -267,6 +267,7 @@ func registerRoutes(m *web.Route) {
|
||||||
|
|
||||||
addWebhookAddRoutes := func() {
|
addWebhookAddRoutes := func() {
|
||||||
m.Get("/{type}/new", repo.WebhooksNew)
|
m.Get("/{type}/new", repo.WebhooksNew)
|
||||||
|
m.Post("/forgejo/new", web.Bind(forms.NewWebhookForm{}), repo.ForgejoHooksNewPost)
|
||||||
m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
|
m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost)
|
||||||
m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
|
m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost)
|
||||||
m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
|
m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost)
|
||||||
|
@ -281,6 +282,7 @@ func registerRoutes(m *web.Route) {
|
||||||
}
|
}
|
||||||
|
|
||||||
addWebhookEditRoutes := func() {
|
addWebhookEditRoutes := func() {
|
||||||
|
m.Post("/forgejo/{id}", web.Bind(forms.NewWebhookForm{}), repo.ForgejoHooksEditPost)
|
||||||
m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
|
m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost)
|
||||||
m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
|
m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost)
|
||||||
m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
|
m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost)
|
||||||
|
|
|
@ -69,7 +69,7 @@ var webhooks = map[webhook_module.HookType]*webhook{
|
||||||
|
|
||||||
// IsValidHookTaskType returns true if a webhook registered
|
// IsValidHookTaskType returns true if a webhook registered
|
||||||
func IsValidHookTaskType(name string) bool {
|
func IsValidHookTaskType(name string) bool {
|
||||||
if name == webhook_module.GITEA || name == webhook_module.GOGS {
|
if name == webhook_module.FORGEJO || name == webhook_module.GITEA || name == webhook_module.GOGS {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
_, ok := webhooks[name]
|
_, ok := webhooks[name]
|
||||||
|
@ -172,7 +172,7 @@ func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook
|
||||||
// Avoid sending "0 new commits" to non-integration relevant webhooks (e.g. slack, discord, etc.).
|
// Avoid sending "0 new commits" to non-integration relevant webhooks (e.g. slack, discord, etc.).
|
||||||
// Integration webhooks (e.g. drone) still receive the required data.
|
// Integration webhooks (e.g. drone) still receive the required data.
|
||||||
if pushEvent, ok := p.(*api.PushPayload); ok &&
|
if pushEvent, ok := p.(*api.PushPayload); ok &&
|
||||||
w.Type != webhook_module.GITEA && w.Type != webhook_module.GOGS &&
|
w.Type != webhook_module.FORGEJO && w.Type != webhook_module.GITEA && w.Type != webhook_module.GOGS &&
|
||||||
len(pushEvent.Commits) == 0 {
|
len(pushEvent.Commits) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,10 @@
|
||||||
{{.locale.Tr "admin.defaulthooks.update_webhook"}}
|
{{.locale.Tr "admin.defaulthooks.update_webhook"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
{{if eq .HookType "gitea"}}
|
{{if eq .HookType "forgejo"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/forgejo.svg">
|
||||||
|
{{else if eq .HookType "gitea"}}
|
||||||
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea-original.svg">
|
||||||
{{else if eq .HookType "gogs"}}
|
{{else if eq .HookType "gogs"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
||||||
{{else if eq .HookType "slack"}}
|
{{else if eq .HookType "slack"}}
|
||||||
|
@ -37,6 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
|
{{template "repo/settings/webhook/forgejo" .}}
|
||||||
{{template "repo/settings/webhook/gitea" .}}
|
{{template "repo/settings/webhook/gitea" .}}
|
||||||
{{template "repo/settings/webhook/gogs" .}}
|
{{template "repo/settings/webhook/gogs" .}}
|
||||||
{{template "repo/settings/webhook/slack" .}}
|
{{template "repo/settings/webhook/slack" .}}
|
||||||
|
|
|
@ -3,8 +3,10 @@
|
||||||
<h4 class="ui top attached header">
|
<h4 class="ui top attached header">
|
||||||
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
{{if eq .HookType "gitea"}}
|
{{if eq .HookType "forgejo"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/forgejo.svg">
|
||||||
|
{{else if eq .HookType "gitea"}}
|
||||||
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea-original.svg">
|
||||||
{{else if eq .HookType "gogs"}}
|
{{else if eq .HookType "gogs"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
||||||
{{else if eq .HookType "slack"}}
|
{{else if eq .HookType "slack"}}
|
||||||
|
|
|
@ -4,8 +4,11 @@
|
||||||
<div class="ui jump dropdown">
|
<div class="ui jump dropdown">
|
||||||
<div class="ui primary tiny button">{{.locale.Tr "repo.settings.add_webhook"}}</div>
|
<div class="ui primary tiny button">{{.locale.Tr "repo.settings.add_webhook"}}</div>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
|
<a class="item" href="{{.BaseLinkNew}}/forgejo/new">
|
||||||
|
<img width="20" height="20" src="{{AssetUrlPrefix}}/img/forgejo.svg">{{.locale.Tr "repo.settings.web_hook_name_forgejo"}}
|
||||||
|
</a>
|
||||||
<a class="item" href="{{.BaseLinkNew}}/gitea/new">
|
<a class="item" href="{{.BaseLinkNew}}/gitea/new">
|
||||||
<img width="20" height="20" src="{{AssetUrlPrefix}}/img/gitea.svg">{{.locale.Tr "repo.settings.web_hook_name_gitea"}}
|
<img width="20" height="20" src="{{AssetUrlPrefix}}/img/gitea-original.svg">{{.locale.Tr "repo.settings.web_hook_name_gitea"}}
|
||||||
</a>
|
</a>
|
||||||
<a class="item" href="{{.BaseLinkNew}}/gogs/new">
|
<a class="item" href="{{.BaseLinkNew}}/gogs/new">
|
||||||
<img width="20" height="20" src="{{AssetUrlPrefix}}/img/gogs.ico">{{.locale.Tr "repo.settings.web_hook_name_gogs"}}
|
<img width="20" height="20" src="{{AssetUrlPrefix}}/img/gogs.ico">{{.locale.Tr "repo.settings.web_hook_name_gogs"}}
|
||||||
|
|
40
templates/repo/settings/webhook/forgejo.tmpl
Normal file
40
templates/repo/settings/webhook/forgejo.tmpl
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{{if eq .HookType "forgejo"}}
|
||||||
|
<p>{{.locale.Tr "repo.settings.add_web_hook_desc" "https://docs.gitea.io/en-us/webhooks/" (.locale.Tr "repo.settings.web_hook_name_forgejo") | Str2html}}</p>
|
||||||
|
<form class="ui form" action="{{.BaseLink}}/forgejo/{{or .Webhook.ID "new"}}" method="post">
|
||||||
|
{{template "base/disable_form_autofill"}}
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<div class="required field {{if .Err_PayloadURL}}error{{end}}">
|
||||||
|
<label for="payload_url">{{.locale.Tr "repo.settings.payload_url"}}</label>
|
||||||
|
<input id="payload_url" name="payload_url" type="url" value="{{.Webhook.URL}}" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{.locale.Tr "repo.settings.http_method"}}</label>
|
||||||
|
<div class="ui selection dropdown">
|
||||||
|
<input type="hidden" id="http_method" name="http_method" value="{{if .Webhook.HTTPMethod}}{{.Webhook.HTTPMethod}}{{else}}POST{{end}}">
|
||||||
|
<div class="default text"></div>
|
||||||
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
|
<div class="menu">
|
||||||
|
<div class="item" data-value="POST">POST</div>
|
||||||
|
<div class="item" data-value="GET">GET</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>{{.locale.Tr "repo.settings.content_type"}}</label>
|
||||||
|
<div class="ui selection dropdown">
|
||||||
|
<input type="hidden" id="content_type" name="content_type" value="{{if .Webhook.ContentType}}{{.Webhook.ContentType}}{{else}}1{{end}}">
|
||||||
|
<div class="default text"></div>
|
||||||
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
|
<div class="menu">
|
||||||
|
<div class="item" data-value="1">application/json</div>
|
||||||
|
<div class="item" data-value="2">application/x-www-form-urlencoded</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field {{if .Err_Secret}}error{{end}}">
|
||||||
|
<label for="secret">{{.locale.Tr "repo.settings.secret"}}</label>
|
||||||
|
<input id="secret" name="secret" type="password" value="{{.Webhook.Secret}}" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
{{template "repo/settings/webhook/settings" .}}
|
||||||
|
</form>
|
||||||
|
{{end}}
|
|
@ -3,8 +3,10 @@
|
||||||
<h4 class="ui top attached header">
|
<h4 class="ui top attached header">
|
||||||
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
{{if eq .HookType "gitea"}}
|
{{if eq .HookType "forgejo"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/forgejo.svg">
|
||||||
|
{{else if eq .HookType "gitea"}}
|
||||||
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea-original.svg">
|
||||||
{{else if eq .HookType "gogs"}}
|
{{else if eq .HookType "gogs"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
||||||
{{else if eq .HookType "slack"}}
|
{{else if eq .HookType "slack"}}
|
||||||
|
@ -29,6 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
|
{{template "repo/settings/webhook/forgejo" .}}
|
||||||
{{template "repo/settings/webhook/gitea" .}}
|
{{template "repo/settings/webhook/gitea" .}}
|
||||||
{{template "repo/settings/webhook/gogs" .}}
|
{{template "repo/settings/webhook/gogs" .}}
|
||||||
{{template "repo/settings/webhook/slack" .}}
|
{{template "repo/settings/webhook/slack" .}}
|
||||||
|
|
1
templates/swagger/v1_json.tmpl
generated
1
templates/swagger/v1_json.tmpl
generated
|
@ -16269,6 +16269,7 @@
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
"forgejo",
|
||||||
"dingtalk",
|
"dingtalk",
|
||||||
"discord",
|
"discord",
|
||||||
"gitea",
|
"gitea",
|
||||||
|
|
|
@ -4,7 +4,9 @@
|
||||||
<h4 class="ui top attached header">
|
<h4 class="ui top attached header">
|
||||||
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
{{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}}
|
||||||
<div class="ui right">
|
<div class="ui right">
|
||||||
{{if eq .HookType "gitea"}}
|
{{if eq .HookType "forgejo"}}
|
||||||
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/forgejo.svg">
|
||||||
|
{{else if eq .HookType "gitea"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gitea.svg">
|
||||||
{{else if eq .HookType "gogs"}}
|
{{else if eq .HookType "gogs"}}
|
||||||
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
<img width="26" height="26" src="{{AssetUrlPrefix}}/img/gogs.ico">
|
||||||
|
|
|
@ -173,3 +173,33 @@ func TestLinksLogin(t *testing.T) {
|
||||||
|
|
||||||
testLinksAsUser("user2", t)
|
testLinksAsUser("user2", t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRedirectsWebhooks(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
//
|
||||||
|
// A redirect means the route exists but not if it performs as intended.
|
||||||
|
//
|
||||||
|
for _, kind := range []string{"forgejo", "gitea"} {
|
||||||
|
redirects := []struct {
|
||||||
|
from string
|
||||||
|
to string
|
||||||
|
verb string
|
||||||
|
}{
|
||||||
|
{from: "/user2/repo1/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
|
||||||
|
{from: "/user/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
|
||||||
|
{from: "/admin/system-hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
|
||||||
|
{from: "/admin/default-hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
|
||||||
|
{from: "/user2/repo1/settings/hooks/" + kind + "/new", to: "/", verb: "POST"},
|
||||||
|
{from: "/admin/system-hooks/" + kind + "/new", to: "/", verb: "POST"},
|
||||||
|
{from: "/admin/default-hooks/" + kind + "/new", to: "/", verb: "POST"},
|
||||||
|
{from: "/user2/repo1/settings/hooks/" + kind + "/1", to: "/", verb: "POST"},
|
||||||
|
{from: "/admin/hooks/" + kind + "/1", to: "/", verb: "POST"},
|
||||||
|
}
|
||||||
|
for _, info := range redirects {
|
||||||
|
req := NewRequest(t, info.verb, info.from)
|
||||||
|
resp := MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
assert.EqualValues(t, path.Join(setting.AppSubURL, info.to), test.RedirectURL(resp), info.from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue