[REFACTOR] webhook matrix endpoints
This commit is contained in:
		
							parent
							
								
									e41e18f87e
								
							
						
					
					
						commit
						8dfbbfef07
					
				
					 16 changed files with 134 additions and 49 deletions
				
			
		| 
						 | 
				
			
			@ -79,8 +79,8 @@ func GetInclude(field reflect.StructField) string {
 | 
			
		|||
	return getRuleBody(field, "Include(")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate validate TODO:
 | 
			
		||||
func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Locale) binding.Errors {
 | 
			
		||||
// Validate populates the data with validation error (if any).
 | 
			
		||||
func Validate(errs binding.Errors, data map[string]any, f any, l translation.Locale) binding.Errors {
 | 
			
		||||
	if errs.Len() == 0 {
 | 
			
		||||
		return errs
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,11 +24,14 @@ import (
 | 
			
		|||
	api "code.gitea.io/gitea/modules/structs"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
	"code.gitea.io/gitea/modules/web"
 | 
			
		||||
	"code.gitea.io/gitea/modules/web/middleware"
 | 
			
		||||
	webhook_module "code.gitea.io/gitea/modules/webhook"
 | 
			
		||||
	"code.gitea.io/gitea/services/context"
 | 
			
		||||
	"code.gitea.io/gitea/services/convert"
 | 
			
		||||
	"code.gitea.io/gitea/services/forms"
 | 
			
		||||
	webhook_service "code.gitea.io/gitea/services/webhook"
 | 
			
		||||
 | 
			
		||||
	"gitea.com/go-chi/binding"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
| 
						 | 
				
			
			@ -201,6 +204,29 @@ type webhookParams struct {
 | 
			
		|||
	Meta        any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func WebhookCreate(ctx *context.Context) {
 | 
			
		||||
	typ := ctx.Params(":type")
 | 
			
		||||
	handler := webhook_service.GetWebhookHandler(typ)
 | 
			
		||||
	if handler == nil {
 | 
			
		||||
		ctx.NotFound("GetWebhookHandler", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fields := handler.FormFields(func(form any) {
 | 
			
		||||
		errs := binding.Bind(ctx.Req, form)
 | 
			
		||||
		middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error will be checked later in ctx.HasError
 | 
			
		||||
	})
 | 
			
		||||
	createWebhook(ctx, webhookParams{
 | 
			
		||||
		Type:        typ,
 | 
			
		||||
		URL:         fields.URL,
 | 
			
		||||
		ContentType: fields.ContentType,
 | 
			
		||||
		Secret:      fields.Secret,
 | 
			
		||||
		HTTPMethod:  fields.HTTPMethod,
 | 
			
		||||
		WebhookForm: fields.WebhookForm,
 | 
			
		||||
		Meta:        fields.Metadata,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createWebhook(ctx *context.Context, params webhookParams) {
 | 
			
		||||
	ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook")
 | 
			
		||||
	ctx.Data["PageIsSettingsHooks"] = true
 | 
			
		||||
| 
						 | 
				
			
			@ -260,6 +286,29 @@ func createWebhook(ctx *context.Context, params webhookParams) {
 | 
			
		|||
	ctx.Redirect(orCtx.Link)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func WebhookUpdate(ctx *context.Context) {
 | 
			
		||||
	typ := ctx.Params(":type")
 | 
			
		||||
	handler := webhook_service.GetWebhookHandler(typ)
 | 
			
		||||
	if handler == nil {
 | 
			
		||||
		ctx.NotFound("GetWebhookHandler", nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fields := handler.FormFields(func(form any) {
 | 
			
		||||
		errs := binding.Bind(ctx.Req, form)
 | 
			
		||||
		middleware.Validate(errs, ctx.Data, form, ctx.Locale) // error will be checked later in ctx.HasError
 | 
			
		||||
	})
 | 
			
		||||
	editWebhook(ctx, webhookParams{
 | 
			
		||||
		Type:        typ,
 | 
			
		||||
		URL:         fields.URL,
 | 
			
		||||
		ContentType: fields.ContentType,
 | 
			
		||||
		Secret:      fields.Secret,
 | 
			
		||||
		HTTPMethod:  fields.HTTPMethod,
 | 
			
		||||
		WebhookForm: fields.WebhookForm,
 | 
			
		||||
		Meta:        fields.Metadata,
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func editWebhook(ctx *context.Context, params webhookParams) {
 | 
			
		||||
	ctx.Data["Title"] = ctx.Tr("repo.settings.update_webhook")
 | 
			
		||||
	ctx.Data["PageIsSettingsHooks"] = true
 | 
			
		||||
| 
						 | 
				
			
			@ -467,33 +516,6 @@ func telegramHookParams(ctx *context.Context) webhookParams {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MatrixHooksNewPost response for creating Matrix webhook
 | 
			
		||||
func MatrixHooksNewPost(ctx *context.Context) {
 | 
			
		||||
	createWebhook(ctx, matrixHookParams(ctx))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MatrixHooksEditPost response for editing Matrix webhook
 | 
			
		||||
func MatrixHooksEditPost(ctx *context.Context) {
 | 
			
		||||
	editWebhook(ctx, matrixHookParams(ctx))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func matrixHookParams(ctx *context.Context) webhookParams {
 | 
			
		||||
	form := web.GetForm(ctx).(*forms.NewMatrixHookForm)
 | 
			
		||||
 | 
			
		||||
	return webhookParams{
 | 
			
		||||
		Type:        webhook_module.MATRIX,
 | 
			
		||||
		URL:         fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)),
 | 
			
		||||
		ContentType: webhook.ContentTypeJSON,
 | 
			
		||||
		HTTPMethod:  http.MethodPut,
 | 
			
		||||
		WebhookForm: form.WebhookForm,
 | 
			
		||||
		Meta: &webhook_service.MatrixMeta{
 | 
			
		||||
			HomeserverURL: form.HomeserverURL,
 | 
			
		||||
			Room:          form.RoomID,
 | 
			
		||||
			MessageType:   form.MessageType,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MSTeamsHooksNewPost response for creating MSTeams webhook
 | 
			
		||||
func MSTeamsHooksNewPost(ctx *context.Context) {
 | 
			
		||||
	createWebhook(ctx, mSTeamsHookParams(ctx))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -409,11 +409,11 @@ func registerRoutes(m *web.Route) {
 | 
			
		|||
		m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo_setting.DiscordHooksNewPost)
 | 
			
		||||
		m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo_setting.DingtalkHooksNewPost)
 | 
			
		||||
		m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo_setting.TelegramHooksNewPost)
 | 
			
		||||
		m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo_setting.MatrixHooksNewPost)
 | 
			
		||||
		m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo_setting.MSTeamsHooksNewPost)
 | 
			
		||||
		m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo_setting.FeishuHooksNewPost)
 | 
			
		||||
		m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo_setting.WechatworkHooksNewPost)
 | 
			
		||||
		m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo_setting.PackagistHooksNewPost)
 | 
			
		||||
		m.Post("/{type}/new", repo_setting.WebhookCreate)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addWebhookEditRoutes := func() {
 | 
			
		||||
| 
						 | 
				
			
			@ -424,11 +424,11 @@ func registerRoutes(m *web.Route) {
 | 
			
		|||
		m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo_setting.DiscordHooksEditPost)
 | 
			
		||||
		m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo_setting.DingtalkHooksEditPost)
 | 
			
		||||
		m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo_setting.TelegramHooksEditPost)
 | 
			
		||||
		m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo_setting.MatrixHooksEditPost)
 | 
			
		||||
		m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo_setting.MSTeamsHooksEditPost)
 | 
			
		||||
		m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo_setting.FeishuHooksEditPost)
 | 
			
		||||
		m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo_setting.WechatworkHooksEditPost)
 | 
			
		||||
		m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo_setting.PackagistHooksEditPost)
 | 
			
		||||
		m.Post("/{type}/{id:[0-9]+}", repo_setting.WebhookUpdate)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addSettingsVariablesRoutes := func() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -371,20 +371,6 @@ func (f *NewTelegramHookForm) Validate(req *http.Request, errs binding.Errors) b
 | 
			
		|||
	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewMatrixHookForm form for creating Matrix hook
 | 
			
		||||
type NewMatrixHookForm struct {
 | 
			
		||||
	HomeserverURL string `binding:"Required;ValidUrl"`
 | 
			
		||||
	RoomID        string `binding:"Required"`
 | 
			
		||||
	MessageType   int
 | 
			
		||||
	WebhookForm
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate validates the fields
 | 
			
		||||
func (f *NewMatrixHookForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
 | 
			
		||||
	ctx := context.GetValidateContext(req)
 | 
			
		||||
	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewMSTeamsHookForm form for creating MS Teams hook
 | 
			
		||||
type NewMSTeamsHookForm struct {
 | 
			
		||||
	PayloadURL string `binding:"Required;ValidUrl"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,6 +35,10 @@ func (dh defaultHandler) Type() webhook_module.HookType {
 | 
			
		|||
 | 
			
		||||
func (defaultHandler) Metadata(*webhook_model.Webhook) any { return nil }
 | 
			
		||||
 | 
			
		||||
func (defaultHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (defaultHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (req *http.Request, body []byte, err error) {
 | 
			
		||||
	switch w.HTTPMethod {
 | 
			
		||||
	case "":
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,9 @@ type dingtalkHandler struct{}
 | 
			
		|||
 | 
			
		||||
func (dingtalkHandler) Type() webhook_module.HookType       { return webhook_module.DINGTALK }
 | 
			
		||||
func (dingtalkHandler) Metadata(*webhook_model.Webhook) any { return nil }
 | 
			
		||||
func (dingtalkHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// DingtalkPayload represents
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,6 +26,10 @@ type discordHandler struct{}
 | 
			
		|||
 | 
			
		||||
func (discordHandler) Type() webhook_module.HookType { return webhook_module.DISCORD }
 | 
			
		||||
 | 
			
		||||
func (discordHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// DiscordEmbedFooter for Embed Footer Structure.
 | 
			
		||||
	DiscordEmbedFooter struct {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,12 @@ import (
 | 
			
		|||
 | 
			
		||||
type feishuHandler struct{}
 | 
			
		||||
 | 
			
		||||
func (feishuHandler) Type() webhook_module.HookType       { return webhook_module.FEISHU }
 | 
			
		||||
func (feishuHandler) Type() webhook_module.HookType { return webhook_module.FEISHU }
 | 
			
		||||
 | 
			
		||||
func (feishuHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (feishuHandler) Metadata(*webhook_model.Webhook) any { return nil }
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,12 +22,40 @@ import (
 | 
			
		|||
	api "code.gitea.io/gitea/modules/structs"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
	webhook_module "code.gitea.io/gitea/modules/webhook"
 | 
			
		||||
	"code.gitea.io/gitea/services/forms"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type matrixHandler struct{}
 | 
			
		||||
 | 
			
		||||
func (matrixHandler) Type() webhook_module.HookType { return webhook_module.MATRIX }
 | 
			
		||||
 | 
			
		||||
func (matrixHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	var form struct {
 | 
			
		||||
		forms.WebhookForm
 | 
			
		||||
		HomeserverURL string `binding:"Required;ValidUrl"`
 | 
			
		||||
		RoomID        string `binding:"Required"`
 | 
			
		||||
		MessageType   int
 | 
			
		||||
 | 
			
		||||
		// enforce requirement of authorization_header
 | 
			
		||||
		// (value will still be set in the embedded WebhookForm)
 | 
			
		||||
		AuthorizationHeader string `binding:"Required"`
 | 
			
		||||
	}
 | 
			
		||||
	bind(&form)
 | 
			
		||||
 | 
			
		||||
	return FormFields{
 | 
			
		||||
		WebhookForm: form.WebhookForm,
 | 
			
		||||
		URL:         fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)),
 | 
			
		||||
		ContentType: webhook_model.ContentTypeJSON,
 | 
			
		||||
		Secret:      "",
 | 
			
		||||
		HTTPMethod:  http.MethodPut,
 | 
			
		||||
		Metadata: &MatrixMeta{
 | 
			
		||||
			HomeserverURL: form.HomeserverURL,
 | 
			
		||||
			Room:          form.RoomID,
 | 
			
		||||
			MessageType:   form.MessageType,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (matrixHandler) NewRequest(ctx context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
 | 
			
		||||
	meta := &MatrixMeta{}
 | 
			
		||||
	if err := json.Unmarshal([]byte(w.Meta), meta); err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,10 @@ type msteamsHandler struct{}
 | 
			
		|||
func (msteamsHandler) Type() webhook_module.HookType       { return webhook_module.MSTEAMS }
 | 
			
		||||
func (msteamsHandler) Metadata(*webhook_model.Webhook) any { return nil }
 | 
			
		||||
 | 
			
		||||
func (msteamsHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// MSTeamsFact for Fact Structure
 | 
			
		||||
	MSTeamsFact struct {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,10 @@ type packagistHandler struct{}
 | 
			
		|||
 | 
			
		||||
func (packagistHandler) Type() webhook_module.HookType { return webhook_module.PACKAGIST }
 | 
			
		||||
 | 
			
		||||
func (packagistHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// PackagistPayload represents a packagist payload
 | 
			
		||||
	// as expected by https://packagist.org/about
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,10 @@ type slackHandler struct{}
 | 
			
		|||
 | 
			
		||||
func (slackHandler) Type() webhook_module.HookType { return webhook_module.SLACK }
 | 
			
		||||
 | 
			
		||||
func (slackHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SlackMeta contains the slack metadata
 | 
			
		||||
type SlackMeta struct {
 | 
			
		||||
	Channel  string `json:"channel"`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,10 @@ type telegramHandler struct{}
 | 
			
		|||
 | 
			
		||||
func (telegramHandler) Type() webhook_module.HookType { return webhook_module.TELEGRAM }
 | 
			
		||||
 | 
			
		||||
func (telegramHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// TelegramPayload represents
 | 
			
		||||
	TelegramPayload struct {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,14 +23,27 @@ import (
 | 
			
		|||
	api "code.gitea.io/gitea/modules/structs"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
	webhook_module "code.gitea.io/gitea/modules/webhook"
 | 
			
		||||
	"code.gitea.io/gitea/services/forms"
 | 
			
		||||
 | 
			
		||||
	"github.com/gobwas/glob"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Handler interface {
 | 
			
		||||
	Type() webhook_module.HookType
 | 
			
		||||
	NewRequest(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error)
 | 
			
		||||
	Metadata(*webhook_model.Webhook) any
 | 
			
		||||
	// FormFields provides a function to bind the request to the form.
 | 
			
		||||
	// If form implements the [binding.Validator] interface, the Validate method will be called
 | 
			
		||||
	FormFields(bind func(form any)) FormFields
 | 
			
		||||
	NewRequest(context.Context, *webhook_model.Webhook, *webhook_model.HookTask) (req *http.Request, body []byte, err error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FormFields struct {
 | 
			
		||||
	forms.WebhookForm
 | 
			
		||||
	URL         string
 | 
			
		||||
	ContentType webhook_model.HookContentType
 | 
			
		||||
	Secret      string
 | 
			
		||||
	HTTPMethod  string
 | 
			
		||||
	Metadata    any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var webhookHandlers = []Handler{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,10 @@ type wechatworkHandler struct{}
 | 
			
		|||
func (wechatworkHandler) Type() webhook_module.HookType       { return webhook_module.WECHATWORK }
 | 
			
		||||
func (wechatworkHandler) Metadata(*webhook_model.Webhook) any { return nil }
 | 
			
		||||
 | 
			
		||||
func (wechatworkHandler) FormFields(bind func(any)) FormFields {
 | 
			
		||||
	panic("TODO")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type (
 | 
			
		||||
	// WechatworkPayload represents
 | 
			
		||||
	WechatworkPayload struct {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -203,8 +203,8 @@ func TestWebhookForms(t *testing.T) {
 | 
			
		|||
		"homeserver_url":       "https://matrix.example.com",
 | 
			
		||||
		"room_id":              "123",
 | 
			
		||||
		"authorization_header": "Bearer 123456",
 | 
			
		||||
		// }, map[string]string{ // authorization_header is actually required, but not enforced (yet)
 | 
			
		||||
		// "authorization_header": "",
 | 
			
		||||
	}, map[string]string{
 | 
			
		||||
		"authorization_header": "",
 | 
			
		||||
	}))
 | 
			
		||||
	t.Run("matrix/optional", testWebhookForms("matrix", session, map[string]string{
 | 
			
		||||
		"homeserver_url": "https://matrix.example.com",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue