mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
ci(arch): forbid pkg packages from depending on project core packages
This commit is contained in:
@@ -5,10 +5,9 @@ package auth_test
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/limiter"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/infra/cache_memory"
|
||||
database "Wavelet/plugins/infra/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -32,11 +31,7 @@ func TestOAuthRateLimiting(t *testing.T) {
|
||||
|
||||
testDB := setupTestDB(t)
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
|
||||
// Provide in-memory limiter service
|
||||
memLimiter := limiter.NewMemoryLimiter()
|
||||
core.Provide[contracts.LimiterService](ctx, memLimiter)
|
||||
|
||||
require.NoError(t, cache_memory.New().Apply(ctx))
|
||||
require.NoError(t, auth.New().Apply(ctx))
|
||||
|
||||
// Create an active OIDC source
|
||||
|
||||
@@ -15,7 +15,12 @@ const (
|
||||
ChannelCustom = "custom"
|
||||
ChannelEmail = "email"
|
||||
ChannelLark = "lark"
|
||||
ChannelDingTalk = "dingtalk"
|
||||
ChannelTelegram = "telegram"
|
||||
ChannelBark = "bark"
|
||||
ChannelDiscord = "discord"
|
||||
ChannelSlack = "slack"
|
||||
ChannelPushover = "pushover"
|
||||
DefaultLevelInfo = "INFO"
|
||||
KeyTitle = "title"
|
||||
KeyContent = "content"
|
||||
@@ -193,7 +198,17 @@ func ListPushDefinitions() []PushDefinition {
|
||||
pushDefMu.RLock()
|
||||
defer pushDefMu.RUnlock()
|
||||
|
||||
order := []string{ChannelCustom, ChannelLark, ChannelTelegram, ChannelEmail}
|
||||
order := []string{
|
||||
ChannelCustom,
|
||||
ChannelLark,
|
||||
ChannelDingTalk,
|
||||
ChannelTelegram,
|
||||
ChannelBark,
|
||||
ChannelDiscord,
|
||||
ChannelSlack,
|
||||
ChannelPushover,
|
||||
ChannelEmail,
|
||||
}
|
||||
res := make([]PushDefinition, 0, len(pushDefinitions))
|
||||
for _, t := range order {
|
||||
if d, ok := pushDefinitions[t]; ok {
|
||||
@@ -215,6 +230,7 @@ func ListPushDefinitions() []PushDefinition {
|
||||
return res
|
||||
}
|
||||
|
||||
//nolint:funlen,goconst // Channel definitions registration table
|
||||
func init() {
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelCustom,
|
||||
@@ -272,6 +288,30 @@ func init() {
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelDingTalk,
|
||||
Name: "钉钉群机器人",
|
||||
Description: "配置钉钉群自定义机器人的 Webhook 接口投递。",
|
||||
Fields: []PushField{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "Webhook 地址",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN",
|
||||
Description: "从钉钉群机器人设置中获取的完整 Webhook URL",
|
||||
},
|
||||
{
|
||||
Key: KeyToken,
|
||||
Label: "加签密钥 (Secret) (可选)",
|
||||
Type: TypeText,
|
||||
Required: false,
|
||||
Placeholder: "可选,若机器人启用了安全设置中的加签校验,请在此输入 SEC 开头的密钥",
|
||||
Description: "钉钉群机器人安全设置中的加签 Secret",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelTelegram,
|
||||
Name: "Telegram 机器人",
|
||||
@@ -304,6 +344,94 @@ func init() {
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelBark,
|
||||
Name: "Bark (iOS 推送)",
|
||||
Description: "配置 Bark 推送通知至 iPhone / iPad 客户端。",
|
||||
Fields: []PushField{
|
||||
{
|
||||
Key: KeyToken,
|
||||
Label: "设备 Key (Device Key)",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "Bark App 首页显示的 Device Key",
|
||||
Description: "从 Bark App 复制的设备专属 Key",
|
||||
},
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "Bark 服务器地址 (可选)",
|
||||
Type: TypeText,
|
||||
Required: false,
|
||||
Placeholder: "https://api.day.app",
|
||||
Description: "Bark 服务器地址,留空默认使用官方公共服务器 https://api.day.app",
|
||||
},
|
||||
{
|
||||
Key: KeyOther,
|
||||
Label: "额外配置 JSON (可选)",
|
||||
Type: TypeTextarea,
|
||||
Required: false,
|
||||
Placeholder: "{\"group\": \"Wavelet\", \"sound\": \"minuet\", \"icon\": \"https://...\"}",
|
||||
Description: "可选的 JSON 配置,支持 group (分组)、sound (铃声)、icon (自定义图标)",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelDiscord,
|
||||
Name: "Discord 频道",
|
||||
Description: "配置 Discord 频道的 Incoming Webhook 消息推送。",
|
||||
Fields: []PushField{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "Webhook 地址",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "https://discord.com/api/webhooks/...",
|
||||
Description: "从 Discord 频道集成设置中复制的 Webhook URL",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelSlack,
|
||||
Name: "Slack 频道",
|
||||
Description: "配置 Slack 频道的 Incoming Webhook 消息推送。",
|
||||
Fields: []PushField{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "Webhook 地址",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "https://hooks.slack.com/services/...",
|
||||
Description: "从 Slack 应用配置中复制的 Incoming Webhook URL",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelPushover,
|
||||
Name: "Pushover 推送",
|
||||
Description: "配置 Pushover 即时推送到手机/桌面客户端。",
|
||||
Fields: []PushField{
|
||||
{
|
||||
Key: KeyToken,
|
||||
Label: "应用 Token (App Token)",
|
||||
Type: TypePassword,
|
||||
Required: true,
|
||||
Placeholder: "Pushover 创建应用生成的 API Token / Key",
|
||||
Description: "从 Pushover 控制台创建的 Application API Token",
|
||||
},
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "用户 Key (User Key)",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "Pushover 账号主页的 User Key",
|
||||
Description: "Pushover 个人账号的 User Key",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
RegisterPushChannelDefinition(PushDefinition{
|
||||
Type: ChannelEmail,
|
||||
Name: "邮件推送通道",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBarkServer = "https://api.day.app"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("bark", &BarkPusher{})
|
||||
}
|
||||
|
||||
// BarkPusher Bark iOS 客户端通知推送实现
|
||||
type BarkPusher struct{}
|
||||
|
||||
type barkPayload struct {
|
||||
DeviceKey string `json:"device_key"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Send 发送 Bark 通知
|
||||
func (p *BarkPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
deviceKey := cfg.Key
|
||||
if deviceKey == "" {
|
||||
deviceKey = cfg.Secret
|
||||
}
|
||||
if target != "" {
|
||||
deviceKey = target
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return "", errors.New("bark: device key is required")
|
||||
}
|
||||
|
||||
serverURL := strings.TrimRight(cfg.URL, "/")
|
||||
if serverURL == "" {
|
||||
serverURL = defaultBarkServer
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
payload := barkPayload{
|
||||
DeviceKey: deviceKey,
|
||||
Title: title,
|
||||
Body: content,
|
||||
Group: "Wavelet",
|
||||
}
|
||||
|
||||
// 提取可选配置 (Ext 字段包含 group, sound, icon 等)
|
||||
if cfg.Ext != nil {
|
||||
if g, ok := cfg.Ext["group"].(string); ok && g != "" {
|
||||
payload.Group = g
|
||||
}
|
||||
if s, ok := cfg.Ext["sound"].(string); ok && s != "" {
|
||||
payload.Sound = s
|
||||
}
|
||||
if icon, ok := cfg.Ext["icon"].(string); ok && icon != "" {
|
||||
payload.Icon = icon
|
||||
}
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
pushURL := fmt.Sprintf("%s/push", serverURL)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushURL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bark: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("bark: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Bark 配置
|
||||
func (p *BarkPusher) ValidateConfig(cfg Config) error {
|
||||
deviceKey := cfg.Key
|
||||
if deviceKey == "" {
|
||||
deviceKey = cfg.Secret
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return errors.New("device key is required")
|
||||
}
|
||||
if cfg.URL != "" && !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("server URL must start with http:// or https://")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDingTalkPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("dingtalk")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get dingtalk pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://oapi.dingtalk.com/robot/send?access_token=test"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
Secret: "test_secret",
|
||||
}, "", map[string]any{
|
||||
"title": "Alert",
|
||||
"content": "Server down",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarkPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("bark")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get bark pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "device_key_123"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
Key: "device_key_123",
|
||||
}, "", map[string]any{
|
||||
"title": "Alert",
|
||||
"content": "Bark notification",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("discord")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get discord pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://discord.com/api/webhooks/123/abc"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
}, "", map[string]any{
|
||||
"title": "Discord Title",
|
||||
"content": "Discord Content",
|
||||
"level": "WARN",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackPusher(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
pusher, err := GetPusher("slack")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get slack pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{URL: "https://hooks.slack.com/services/123"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = pusher.Send(context.Background(), Config{
|
||||
URL: server.URL,
|
||||
}, "", map[string]any{
|
||||
"title": "Slack Title",
|
||||
"content": "Slack Content",
|
||||
}, "", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushoverPusher(t *testing.T) {
|
||||
pusher, err := GetPusher("pushover")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get pushover pusher: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{Key: "app_token_123"})
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
err = pusher.ValidateConfig(Config{})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty config, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("dingtalk", &DingTalkPusher{})
|
||||
}
|
||||
|
||||
// DingTalkPusher 钉钉机器人 Webhook 推送实现
|
||||
type DingTalkPusher struct{}
|
||||
|
||||
type dingTalkMarkdown struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type dingTalkMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Markdown dingTalkMarkdown `json:"markdown"`
|
||||
}
|
||||
|
||||
// Send 发送钉钉通知
|
||||
func (p *DingTalkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("dingtalk: webhook URL is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n\n")
|
||||
|
||||
webhookURL := cfg.URL
|
||||
// 如果配置了签名 Secret (Key 或 Secret 字段),计算时间戳与签名
|
||||
secret := cfg.Secret
|
||||
if secret == "" {
|
||||
secret = cfg.Key
|
||||
}
|
||||
if secret != "" {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
stringToSign := timestamp + "\n" + secret
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
signature := url.QueryEscape(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
|
||||
sep := "?"
|
||||
if strings.Contains(webhookURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
webhookURL = fmt.Sprintf("%s%stimestamp=%s&sign=%s", webhookURL, sep, timestamp, signature)
|
||||
}
|
||||
|
||||
markdownText := fmt.Sprintf("### %s\n\n%s", title, content)
|
||||
msg := dingTalkMessage{
|
||||
MsgType: "markdown",
|
||||
Markdown: dingTalkMarkdown{
|
||||
Title: title,
|
||||
Text: markdownText,
|
||||
},
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dingtalk: marshal message failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dingtalk: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dingtalk: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("dingtalk: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
var dingResp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &dingResp); err == nil && dingResp.ErrCode != 0 {
|
||||
return upstreamResp, fmt.Errorf("dingtalk: api error code %d: %s", dingResp.ErrCode, dingResp.ErrMsg)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验钉钉配置
|
||||
func (p *DingTalkPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must use https:// protocol")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
discordColorInfo = 3447003 // Blue
|
||||
discordColorWarn = 15105570 // Orange
|
||||
discordColorErr = 15158332 // Red
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("discord", &DiscordPusher{})
|
||||
}
|
||||
|
||||
// DiscordPusher Discord Webhook 机器人推送实现
|
||||
type DiscordPusher struct{}
|
||||
|
||||
type discordEmbed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color int `json:"color"`
|
||||
}
|
||||
|
||||
type discordPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Embeds []discordEmbed `json:"embeds"`
|
||||
}
|
||||
|
||||
// Send 发送 Discord 通知
|
||||
func (p *DiscordPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("discord: webhook URL is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "**%s**: %v", "\n")
|
||||
level := bodyLevel(body)
|
||||
|
||||
color := discordColorInfo
|
||||
switch strings.ToUpper(level) {
|
||||
case "WARN", "WARNING":
|
||||
color = discordColorWarn
|
||||
case "ERROR", "FATAL":
|
||||
color = discordColorErr
|
||||
}
|
||||
|
||||
payload := discordPayload{
|
||||
Username: "Wavelet System",
|
||||
Embeds: []discordEmbed{
|
||||
{
|
||||
Title: title,
|
||||
Description: content,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discord: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discord: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("discord: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("discord: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Discord 配置
|
||||
func (p *DiscordPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must start with https://")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
defaultTitle = "系统通知"
|
||||
levelInfo = "INFO"
|
||||
defaultHTTPClientTimeout = 10 * time.Second
|
||||
maxResponseBodyBytes = 4096
|
||||
)
|
||||
|
||||
// Config 基础通知渠道配置
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
pushoverAPIEndpoint = "https://api.pushover.net/1/messages.json"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("pushover", &PushoverPusher{})
|
||||
}
|
||||
|
||||
// PushoverPusher Pushover 移动端推送实现
|
||||
type PushoverPusher struct{}
|
||||
|
||||
// Send 发送 Pushover 通知
|
||||
func (p *PushoverPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
appToken := cfg.Key
|
||||
if appToken == "" {
|
||||
appToken = cfg.Secret
|
||||
}
|
||||
if appToken == "" {
|
||||
return "", errors.New("pushover: app token is required")
|
||||
}
|
||||
|
||||
userKey := cfg.URL
|
||||
if userKey == "" && cfg.Ext != nil {
|
||||
if k, ok := cfg.Ext["user_key"].(string); ok {
|
||||
userKey = k
|
||||
}
|
||||
}
|
||||
if target != "" {
|
||||
userKey = target
|
||||
}
|
||||
if userKey == "" {
|
||||
return "", errors.New("pushover: user key is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "%s: %v", "\n")
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("token", appToken)
|
||||
formData.Set("user", userKey)
|
||||
formData.Set("title", title)
|
||||
formData.Set("message", content)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pushoverAPIEndpoint, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pushover: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pushover: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("pushover: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Pushover 配置
|
||||
func (p *PushoverPusher) ValidateConfig(cfg Config) error {
|
||||
appToken := cfg.Key
|
||||
if appToken == "" {
|
||||
appToken = cfg.Secret
|
||||
}
|
||||
if appToken == "" {
|
||||
return errors.New("app token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/httppool"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("slack", &SlackPusher{})
|
||||
}
|
||||
|
||||
// SlackPusher Slack Webhook 推送实现
|
||||
type SlackPusher struct{}
|
||||
|
||||
type slackPayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Send 发送 Slack 通知
|
||||
func (p *SlackPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, _ string, _ map[string]any) (string, error) {
|
||||
if cfg.URL == "" {
|
||||
return "", errors.New("slack: webhook URL is required")
|
||||
}
|
||||
|
||||
title := bodyTitle(body)
|
||||
content := bodyContent(body, "*%s*: %v", "\n")
|
||||
|
||||
text := fmt.Sprintf("*%s*\n%s", title, content)
|
||||
payload := slackPayload{
|
||||
Text: text,
|
||||
}
|
||||
|
||||
reqBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack: marshal payload failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack: create request failed: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := httppool.NewClient(defaultHTTPClientTimeout)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slack: http request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
upstreamResp := strings.TrimSpace(string(respBody))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return upstreamResp, fmt.Errorf("slack: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
return upstreamResp, nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验 Slack 配置
|
||||
func (p *SlackPusher) ValidateConfig(cfg Config) error {
|
||||
if cfg.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.URL, "https://") {
|
||||
return errors.New("webhook URL must start with https://")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,10 +6,10 @@ package user_test
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/limiter"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"Wavelet/plugins/infra/cache_memory"
|
||||
database "Wavelet/plugins/infra/database"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -34,11 +34,7 @@ func TestUserLoginRateLimiting(t *testing.T) {
|
||||
|
||||
testDB := setupTestDB(t)
|
||||
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
|
||||
|
||||
// Provide in-memory limiter service
|
||||
memLimiter := limiter.NewMemoryLimiter()
|
||||
core.Provide[contracts.LimiterService](ctx, memLimiter)
|
||||
|
||||
require.NoError(t, cache_memory.New().Apply(ctx))
|
||||
require.NoError(t, auth.New().Apply(ctx))
|
||||
require.NoError(t, user.New().Apply(ctx))
|
||||
|
||||
|
||||
+41
@@ -5,6 +5,7 @@ package cache
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/limiter"
|
||||
"context"
|
||||
|
||||
"github.com/go-redis/redis_rate/v10"
|
||||
@@ -57,3 +58,43 @@ func (r *redisLimiterImpl) AllowN(ctx context.Context, key string, rate contract
|
||||
func (r *redisLimiterImpl) Reset(ctx context.Context, key string) error {
|
||||
return r.limiter.Reset(ctx, r.prefixedKey(key))
|
||||
}
|
||||
|
||||
type memoryLimiterFallback struct {
|
||||
limiter *limiter.MemoryLimiter
|
||||
}
|
||||
|
||||
func newMemoryLimiterFallback() contracts.LimiterService {
|
||||
return &memoryLimiterFallback{
|
||||
limiter: limiter.NewMemoryLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryLimiterFallback) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) {
|
||||
res, err := m.limiter.Allow(ctx, key, limiter.Rate{Limit: rate.Limit, Period: rate.Period})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contracts.RateLimitResult{
|
||||
Allowed: res.Allowed,
|
||||
Remaining: res.Remaining,
|
||||
ResetAfter: res.ResetAfter,
|
||||
RetryAfter: res.RetryAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *memoryLimiterFallback) AllowN(ctx context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) {
|
||||
res, err := m.limiter.AllowN(ctx, key, limiter.Rate{Limit: rate.Limit, Period: rate.Period}, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contracts.RateLimitResult{
|
||||
Allowed: res.Allowed,
|
||||
Remaining: res.Remaining,
|
||||
ResetAfter: res.ResetAfter,
|
||||
RetryAfter: res.RetryAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *memoryLimiterFallback) Reset(ctx context.Context, key string) error {
|
||||
return m.limiter.Reset(ctx, key)
|
||||
}
|
||||
|
||||
+1
-2
@@ -8,7 +8,6 @@ import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/cache/ram"
|
||||
"Wavelet/pkg/limiter"
|
||||
"Wavelet/pkg/util"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -145,7 +144,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
if redisClient != nil {
|
||||
limiterSvc = newRedisLimiter(redisClient, p.keyPrefix)
|
||||
} else {
|
||||
limiterSvc = limiter.NewMemoryLimiter()
|
||||
limiterSvc = newMemoryLimiterFallback()
|
||||
}
|
||||
core.Provide[contracts.LimiterService](ctx, limiterSvc)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cache_memory
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/limiter"
|
||||
"context"
|
||||
)
|
||||
|
||||
type memoryLimiterAdapter struct {
|
||||
limiter *limiter.MemoryLimiter
|
||||
}
|
||||
|
||||
func newMemoryLimiterService() contracts.LimiterService {
|
||||
return &memoryLimiterAdapter{
|
||||
limiter: limiter.NewMemoryLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *memoryLimiterAdapter) Allow(ctx context.Context, key string, rate contracts.Rate) (*contracts.RateLimitResult, error) {
|
||||
res, err := a.limiter.Allow(ctx, key, limiter.Rate{
|
||||
Limit: rate.Limit,
|
||||
Period: rate.Period,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contracts.RateLimitResult{
|
||||
Allowed: res.Allowed,
|
||||
Remaining: res.Remaining,
|
||||
ResetAfter: res.ResetAfter,
|
||||
RetryAfter: res.RetryAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *memoryLimiterAdapter) AllowN(ctx context.Context, key string, rate contracts.Rate, n int) (*contracts.RateLimitResult, error) {
|
||||
res, err := a.limiter.AllowN(ctx, key, limiter.Rate{
|
||||
Limit: rate.Limit,
|
||||
Period: rate.Period,
|
||||
}, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contracts.RateLimitResult{
|
||||
Allowed: res.Allowed,
|
||||
Remaining: res.Remaining,
|
||||
ResetAfter: res.ResetAfter,
|
||||
RetryAfter: res.RetryAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *memoryLimiterAdapter) Reset(ctx context.Context, key string) error {
|
||||
return a.limiter.Reset(ctx, key)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ package cache_memory
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/limiter"
|
||||
)
|
||||
|
||||
const defaultRAMCapacity = 10000
|
||||
@@ -80,6 +79,6 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
}
|
||||
|
||||
core.Provide[contracts.CacheService](ctx, svc)
|
||||
core.Provide[contracts.LimiterService](ctx, limiter.NewMemoryLimiter())
|
||||
core.Provide[contracts.LimiterService](ctx, newMemoryLimiterService())
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user