Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
74
pkg/services/lark/lark_config.go
Normal file
74
pkg/services/lark/lark_config.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package lark
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// Scheme is the identifier for the Lark service protocol.
|
||||
const Scheme = "lark"
|
||||
|
||||
// Config represents the configuration for the Lark service.
|
||||
type Config struct {
|
||||
Host string `default:"open.larksuite.com" desc:"Custom bot URL Host" url:"Host"`
|
||||
Secret string `default:"" desc:"Custom bot secret" key:"secret"`
|
||||
Path string ` desc:"Custom bot token" url:"Path"`
|
||||
Title string `default:"" desc:"Message Title" key:"title"`
|
||||
Link string `default:"" desc:"Optional link URL" key:"link"`
|
||||
}
|
||||
|
||||
// Enums returns a map of enum formatters (none for this service).
|
||||
func (config *Config) Enums() map[string]types.EnumFormatter {
|
||||
return map[string]types.EnumFormatter{}
|
||||
}
|
||||
|
||||
// GetURL constructs a URL from the Config fields.
|
||||
func (config *Config) GetURL() *url.URL {
|
||||
resolver := format.NewPropKeyResolver(config)
|
||||
|
||||
return config.getURL(&resolver)
|
||||
}
|
||||
|
||||
// getURL constructs a URL using the provided resolver.
|
||||
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
|
||||
return &url.URL{
|
||||
Host: config.Host,
|
||||
Path: "/" + config.Path,
|
||||
Scheme: Scheme,
|
||||
ForceQuery: true,
|
||||
RawQuery: format.BuildQuery(resolver),
|
||||
}
|
||||
}
|
||||
|
||||
// SetURL updates the Config from a URL.
|
||||
func (config *Config) SetURL(url *url.URL) error {
|
||||
resolver := format.NewPropKeyResolver(config)
|
||||
|
||||
return config.setURL(&resolver, url)
|
||||
}
|
||||
|
||||
// setURL updates the Config from a URL using the provided resolver.
|
||||
// It sets the host, path, and query parameters, validating host and path, and returns an error if parsing or validation fails.
|
||||
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
|
||||
config.Host = url.Host
|
||||
if config.Host != larkHost && config.Host != feishuHost {
|
||||
return ErrInvalidHost
|
||||
}
|
||||
|
||||
config.Path = strings.Trim(url.Path, "/")
|
||||
if config.Path == "" {
|
||||
return ErrNoPath
|
||||
}
|
||||
|
||||
for key, vals := range url.Query() {
|
||||
if err := resolver.Set(key, vals[0]); err != nil {
|
||||
return fmt.Errorf("setting query parameter %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
59
pkg/services/lark/lark_message.go
Normal file
59
pkg/services/lark/lark_message.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
package lark
|
||||
|
||||
// RequestBody represents the payload sent to the Lark API.
|
||||
type RequestBody struct {
|
||||
MsgType MsgType `json:"msg_type"`
|
||||
Content Content `json:"content"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Sign string `json:"sign,omitempty"`
|
||||
}
|
||||
|
||||
// MsgType defines the type of message to send.
|
||||
type MsgType string
|
||||
|
||||
// Constants for message types supported by Lark.
|
||||
const (
|
||||
MsgTypeText MsgType = "text"
|
||||
MsgTypePost MsgType = "post"
|
||||
)
|
||||
|
||||
// Content holds the message content, supporting text or post formats.
|
||||
type Content struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Post *Post `json:"post,omitempty"`
|
||||
}
|
||||
|
||||
// Post represents a rich post message with language-specific content.
|
||||
type Post struct {
|
||||
Zh *Message `json:"zh_cn,omitempty"` // Chinese content
|
||||
En *Message `json:"en_us,omitempty"` // English content
|
||||
}
|
||||
|
||||
// Message defines the structure of a post message.
|
||||
type Message struct {
|
||||
Title string `json:"title"`
|
||||
Content [][]Item `json:"content"`
|
||||
}
|
||||
|
||||
// Item represents a content element within a post message.
|
||||
type Item struct {
|
||||
Tag TagValue `json:"tag"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Link string `json:"href,omitempty"`
|
||||
}
|
||||
|
||||
// TagValue specifies the type of content item.
|
||||
type TagValue string
|
||||
|
||||
// Constants for tag values supported by Lark.
|
||||
const (
|
||||
TagValueText TagValue = "text"
|
||||
TagValueLink TagValue = "a"
|
||||
)
|
||||
|
||||
// Response represents the API response from Lark.
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
237
pkg/services/lark/lark_service.go
Normal file
237
pkg/services/lark/lark_service.go
Normal file
|
@ -0,0 +1,237 @@
|
|||
package lark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// Constants for the Lark service configuration and limits.
|
||||
const (
|
||||
apiFormat = "https://%s/open-apis/bot/v2/hook/%s" // API endpoint format
|
||||
maxLength = 4096 // Maximum message length in bytes
|
||||
defaultTime = 30 * time.Second // Default HTTP client timeout
|
||||
)
|
||||
|
||||
const (
|
||||
larkHost = "open.larksuite.com"
|
||||
feishuHost = "open.feishu.cn"
|
||||
)
|
||||
|
||||
// Error variables for the Lark service.
|
||||
var (
|
||||
ErrInvalidHost = errors.New("invalid host, use 'open.larksuite.com' or 'open.feishu.cn'")
|
||||
ErrNoPath = errors.New(
|
||||
"no path, path like 'xxx' in 'https://open.larksuite.com/open-apis/bot/v2/hook/xxx'",
|
||||
)
|
||||
ErrLargeMessage = errors.New("message exceeds the max length")
|
||||
ErrMissingHost = errors.New("host is required but not specified in the configuration")
|
||||
ErrSendFailed = errors.New("failed to send notification to Lark")
|
||||
ErrInvalidSignature = errors.New("failed to generate valid signature")
|
||||
)
|
||||
|
||||
// httpClient is configured with a default timeout.
|
||||
var httpClient = &http.Client{Timeout: defaultTime}
|
||||
|
||||
// Service sends notifications to Lark.
|
||||
type Service struct {
|
||||
standard.Standard
|
||||
config *Config
|
||||
pkr format.PropKeyResolver
|
||||
}
|
||||
|
||||
// Send delivers a notification message to Lark.
|
||||
func (service *Service) Send(message string, params *types.Params) error {
|
||||
if len(message) > maxLength {
|
||||
return ErrLargeMessage
|
||||
}
|
||||
|
||||
config := *service.config
|
||||
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
|
||||
return fmt.Errorf("updating params: %w", err)
|
||||
}
|
||||
|
||||
if config.Host != larkHost && config.Host != feishuHost {
|
||||
return ErrInvalidHost
|
||||
}
|
||||
|
||||
if config.Path == "" {
|
||||
return ErrNoPath
|
||||
}
|
||||
|
||||
return service.doSend(config, message, params)
|
||||
}
|
||||
|
||||
// Initialize configures the service with a URL and logger.
|
||||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
|
||||
service.SetLogger(logger)
|
||||
service.config = &Config{}
|
||||
service.pkr = format.NewPropKeyResolver(service.config)
|
||||
|
||||
return service.config.SetURL(configURL)
|
||||
}
|
||||
|
||||
// GetID returns the service identifier.
|
||||
func (service *Service) GetID() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
// doSend sends the notification to Lark using the configured API URL.
|
||||
func (service *Service) doSend(config Config, message string, params *types.Params) error {
|
||||
if config.Host == "" {
|
||||
return ErrMissingHost
|
||||
}
|
||||
|
||||
postURL := fmt.Sprintf(apiFormat, config.Host, config.Path)
|
||||
|
||||
payload, err := service.preparePayload(message, config, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.sendRequest(postURL, payload)
|
||||
}
|
||||
|
||||
// preparePayload constructs and marshals the request payload for the Lark API.
|
||||
func (service *Service) preparePayload(
|
||||
message string,
|
||||
config Config,
|
||||
params *types.Params,
|
||||
) ([]byte, error) {
|
||||
body := service.getRequestBody(message, config.Title, config.Secret, params)
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
service.Logf("Lark Request Body: %s", string(data))
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// sendRequest performs the HTTP POST request to the Lark API and handles the response.
|
||||
func (service *Service) sendRequest(postURL string, payload []byte) error {
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
postURL,
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: making HTTP request: %w", ErrSendFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return service.handleResponse(resp)
|
||||
}
|
||||
|
||||
// handleResponse processes the API response and checks for errors.
|
||||
func (service *Service) handleResponse(resp *http.Response) error {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: unexpected status %s", ErrSendFailed, resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("unmarshaling response: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return fmt.Errorf(
|
||||
"%w: server returned code %d: %s",
|
||||
ErrSendFailed,
|
||||
response.Code,
|
||||
response.Msg,
|
||||
)
|
||||
}
|
||||
|
||||
service.Logf(
|
||||
"Notification sent successfully to %s/%s",
|
||||
service.config.Host,
|
||||
service.config.Path,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// genSign generates a signature for the request using the secret and timestamp.
|
||||
func (service *Service) genSign(secret string, timestamp int64) (string, error) {
|
||||
stringToSign := fmt.Sprintf("%v\n%s", timestamp, secret)
|
||||
|
||||
h := hmac.New(sha256.New, []byte(stringToSign))
|
||||
if _, err := h.Write([]byte{}); err != nil {
|
||||
return "", fmt.Errorf("%w: computing HMAC: %w", ErrInvalidSignature, err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// getRequestBody constructs the request body for the Lark API, supporting rich content via params.
|
||||
func (service *Service) getRequestBody(
|
||||
message, title, secret string,
|
||||
params *types.Params,
|
||||
) *RequestBody {
|
||||
body := &RequestBody{}
|
||||
|
||||
if secret != "" {
|
||||
ts := time.Now().Unix()
|
||||
body.Timestamp = strconv.FormatInt(ts, 10)
|
||||
|
||||
sign, err := service.genSign(secret, ts)
|
||||
if err != nil {
|
||||
sign = "" // Fallback to empty string on error
|
||||
}
|
||||
|
||||
body.Sign = sign
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
body.MsgType = MsgTypeText
|
||||
body.Content.Text = message
|
||||
} else {
|
||||
body.MsgType = MsgTypePost
|
||||
content := [][]Item{{{Tag: TagValueText, Text: message}}}
|
||||
|
||||
if params != nil {
|
||||
if link, ok := (*params)["link"]; ok && link != "" {
|
||||
content = append(content, []Item{{Tag: TagValueLink, Text: "More Info", Link: link}})
|
||||
}
|
||||
}
|
||||
|
||||
body.Content.Post = &Post{
|
||||
En: &Message{
|
||||
Title: title,
|
||||
Content: content,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
215
pkg/services/lark/lark_test.go
Normal file
215
pkg/services/lark/lark_test.go
Normal file
|
@ -0,0 +1,215 @@
|
|||
package lark
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
func TestLark(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Lark Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
service *Service
|
||||
logger *log.Logger
|
||||
_ = ginkgo.BeforeSuite(func() {
|
||||
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
|
||||
})
|
||||
)
|
||||
|
||||
const fullURL = "lark://open.larksuite.com/token?secret=sss"
|
||||
|
||||
var _ = ginkgo.Describe("Lark Test", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
service = &Service{}
|
||||
})
|
||||
|
||||
ginkgo.When("parsing the configuration URL", func() {
|
||||
ginkgo.It("should be identical after de-/serialization", func() {
|
||||
url := testutils.URLMust(fullURL)
|
||||
config := &Config{}
|
||||
pkr := format.NewPropKeyResolver(config)
|
||||
err := config.setURL(&pkr, url)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
outputURL := config.GetURL()
|
||||
ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, fullURL)
|
||||
gomega.Expect(outputURL.String()).To(gomega.Equal(fullURL))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Context("basic service API methods", func() {
|
||||
var config *Config
|
||||
ginkgo.BeforeEach(func() {
|
||||
config = &Config{}
|
||||
})
|
||||
ginkgo.It("should not allow getting invalid query values", func() {
|
||||
testutils.TestConfigGetInvalidQueryValue(config)
|
||||
})
|
||||
ginkgo.It("should not allow setting invalid query values", func() {
|
||||
testutils.TestConfigSetInvalidQueryValue(
|
||||
config,
|
||||
"lark://endpoint/token?secret=sss&foo=bar",
|
||||
)
|
||||
})
|
||||
ginkgo.It("should have the expected number of fields and enums", func() {
|
||||
testutils.TestConfigGetEnumsCount(config, 0)
|
||||
testutils.TestConfigGetFieldsCount(config, 3)
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("initializing the service", func() {
|
||||
ginkgo.It("should fail with invalid host", func() {
|
||||
err := service.Initialize(testutils.URLMust("lark://invalid.com/token"), logger)
|
||||
gomega.Expect(err).To(gomega.MatchError(ErrInvalidHost))
|
||||
})
|
||||
ginkgo.It("should fail with no path", func() {
|
||||
err := service.Initialize(testutils.URLMust("lark://open.larksuite.com"), logger)
|
||||
gomega.Expect(err).To(gomega.MatchError(ErrNoPath))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message", func() {
|
||||
ginkgo.When("the message is too large", func() {
|
||||
ginkgo.It("should return large message error", func() {
|
||||
data := make([]string, 410)
|
||||
for i := range data {
|
||||
data[i] = "0123456789"
|
||||
}
|
||||
message := strings.Join(data, "")
|
||||
service := Service{config: &Config{Host: larkHost, Path: "token"}}
|
||||
gomega.Expect(service.Send(message, nil)).To(gomega.MatchError(ErrLargeMessage))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("an invalid param is passed", func() {
|
||||
ginkgo.It("should fail to send messages", func() {
|
||||
service := Service{config: &Config{Host: larkHost, Path: "token"}}
|
||||
gomega.Expect(
|
||||
service.Send("test message", &types.Params{"invalid": "value"}),
|
||||
).To(gomega.MatchError(gomega.ContainSubstring("not a valid config key: invalid")))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Context("sending message by HTTP", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
httpmock.ActivateNonDefault(httpClient)
|
||||
})
|
||||
ginkgo.AfterEach(func() {
|
||||
httpmock.DeactivateAndReset()
|
||||
})
|
||||
|
||||
ginkgo.It("should send text message successfully", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewJsonResponderOrPanic(
|
||||
http.StatusOK,
|
||||
map[string]any{"code": 0, "msg": "success"},
|
||||
),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", nil)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
})
|
||||
|
||||
ginkgo.It("should send post message with title successfully", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewJsonResponderOrPanic(
|
||||
http.StatusOK,
|
||||
map[string]any{"code": 0, "msg": "success"},
|
||||
),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", &types.Params{"title": "title"})
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
})
|
||||
|
||||
ginkgo.It("should send post message with link successfully", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewJsonResponderOrPanic(
|
||||
http.StatusOK,
|
||||
map[string]any{"code": 0, "msg": "success"},
|
||||
),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send(
|
||||
"message",
|
||||
&types.Params{"title": "title", "link": "https://example.com"},
|
||||
)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on network failure", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewErrorResponder(errors.New("network error")),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", nil)
|
||||
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("network error")))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on invalid JSON response", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewStringResponder(http.StatusOK, "some response"),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", nil)
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError(gomega.ContainSubstring("invalid character")))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on non-zero response code", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewJsonResponderOrPanic(
|
||||
http.StatusOK,
|
||||
map[string]any{"code": 1, "msg": "some error"},
|
||||
),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", nil)
|
||||
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("some error")))
|
||||
})
|
||||
|
||||
ginkgo.It("should fail on HTTP 400 status", func() {
|
||||
httpmock.RegisterResponder(
|
||||
http.MethodPost,
|
||||
"/open-apis/bot/v2/hook/token",
|
||||
httpmock.NewStringResponder(http.StatusBadRequest, "bad request"),
|
||||
)
|
||||
err := service.Initialize(testutils.URLMust(fullURL), logger)
|
||||
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
|
||||
err = service.Send("message", nil)
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError(gomega.ContainSubstring("unexpected status 400")))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue