diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go index 4ac70da..8601c27 100644 --- a/internal/server/automatic_task_notifications.go +++ b/internal/server/automatic_task_notifications.go @@ -57,7 +57,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa }, "\n"), Time: run.FinishedAt, Task: task, Run: run, } - for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} { + for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} { setting, err := s.store.NotificationSetting(ctx, channel) if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue @@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m return sendAutomaticTaskWebhook(ctx, config, message) case "wecom": return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message)) + case "lark": + return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message)) default: return fmt.Errorf("unsupported notification channel %q", channel) } diff --git a/internal/server/lark_notification.go b/internal/server/lark_notification.go new file mode 100644 index 0000000..851cf9f --- /dev/null +++ b/internal/server/lark_notification.go @@ -0,0 +1,282 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const maxLarkPayloadBytes = 20 << 10 + +var larkTemplateVariableNames = []string{ + "event", + "title", + "message", + "timestamp", + "content", + "number", + "device_id", + "device_name", + "device_label", + "time", +} + +var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`) + +var larkWebhookHosts = map[string]struct{}{ + "open.feishu.cn": {}, + "open.larksuite.com": {}, +} + +type larkTemplateValues map[string]string + +func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) { + encodedValues := make(map[string]string, len(larkTemplateVariableNames)) + for _, name := range larkTemplateVariableNames { + encoded, err := json.Marshal(values[name]) + if err != nil { + return nil, fmt.Errorf("encode Lark template value %q: %w", name, err) + } + encodedValues[name] = string(encoded) + } + unsupported := false + rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string { + name := placeholder[2 : len(placeholder)-2] + encoded, ok := encodedValues[name] + if !ok { + unsupported = true + return placeholder + } + return encoded + }) + remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "") + if unsupported || strings.Contains(remainder, "{{") { + return nil, errors.New("lark.payload_template contains an unsupported variable") + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 { + return nil, errors.New("lark.payload_template must render to a non-empty JSON object") + } + if len(rendered) > maxLarkPayloadBytes { + return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit") + } + return []byte(rendered), nil +} + +func larkSignature(timestamp int64, secret string) string { + key := strconv.FormatInt(timestamp, 10) + "\n" + secret + signature := hmac.New(sha256.New, []byte(key)) + return base64.StdEncoding.EncodeToString(signature.Sum(nil)) +} + +func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) { + if secret == "" { + return payload, nil + } + var document map[string]json.RawMessage + if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 { + return nil, errors.New("lark payload must be a non-empty JSON object") + } + timestamp := now.Unix() + document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10)) + document["sign"], _ = json.Marshal(larkSignature(timestamp, secret)) + signed, err := json.Marshal(document) + if err != nil { + return nil, fmt.Errorf("encode signed Lark payload: %w", err) + } + if len(signed) > maxLarkPayloadBytes { + return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing") + } + return signed, nil +} + +func validateLarkResponse(status int, body []byte) error { + var result struct { + Code *int `json:"code"` + StatusCode *int `json:"StatusCode"` + } + if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil { + return fmt.Errorf("%w: Lark response was not successful", errProviderRejected) + } + if result.Code != nil { + if *result.Code == 0 { + return nil + } + return fmt.Errorf("%w: Lark response was not successful", errProviderRejected) + } + if result.StatusCode == nil || *result.StatusCode != 0 { + return fmt.Errorf("%w: Lark response was not successful", errProviderRejected) + } + return nil +} + +func parseLarkWebhookURL(raw string) (*url.URL, error) { + parsed, err := parseOutboundURL(raw, true) + if err != nil { + return nil, err + } + if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok { + return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com") + } + if parsed.Port() != "" && parsed.Port() != "443" { + return nil, errors.New("Lark group bot webhook must use the default HTTPS port") + } + const prefix = "/open-apis/bot/v2/hook/" + token := strings.TrimPrefix(parsed.Path, prefix) + if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" || + parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" { + return nil, errors.New("Lark group bot webhook path is invalid") + } + return parsed, nil +} + +func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) { + parsed, err := parseLarkWebhookURL(raw) + if err != nil { + return nil, err + } + if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil { + return nil, err + } + return parsed, nil +} + +func larkTestValues(now time.Time) larkTemplateValues { + return larkTemplateValues{ + "event": "test", "title": "vocat", "message": "vocat notification test", + "timestamp": now.UTC().Format(time.RFC3339), + } +} + +func larkSMSValues(message smsNotification) larkTemplateValues { + return larkTemplateValues{ + "event": "sms.received", + "title": "收到新短信", + "message": message.Text(), + "timestamp": message.Time.UTC().Format(time.RFC3339), + "content": message.Content, + "number": message.Number, + "device_id": message.DeviceID, + "device_name": message.DeviceName, + "device_label": message.DeviceLabel, + "time": message.Time.Local().Format("2006-01-02 15:04:05"), + } +} + +func larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues { + return larkTemplateValues{ + "event": "automatic_task.completed", + "title": message.Title, + "message": message.Text, + "timestamp": message.Time.UTC().Format(time.RFC3339), + "content": "", + "number": "", + "device_id": "", + "device_name": "", + "device_label": "", + "time": "", + } +} + +func validateLarkNotificationConfig(config map[string]any) error { + if configString(config, "url") == "" { + return errors.New("lark.url is required") + } + template := configString(config, "payload_template") + if template == "" { + return errors.New("lark.payload_template is required") + } + if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled { + secret := configString(config, "secret") + if secret == "" { + return errors.New("lark.secret is required when signing is enabled") + } + } + payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0))) + if err != nil { + return err + } + _, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0)) + return err +} + +func larkSigningSecret(config map[string]any) string { + enabled, _ := config["signing_enabled"].(bool) + if !enabled { + return "" + } + return configString(config, "secret") +} + +func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error { + if err := validateLarkNotificationConfig(config); err != nil { + return err + } + payload, err := renderLarkPayload(configString(config, "payload_template"), values) + if err != nil { + return err + } + payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now()) + if err != nil { + return err + } + parsed, err := validateLarkWebhookURL(ctx, configString(config, "url")) + if err != nil { + return err + } + client, err := restrictedHTTPClient(ctx, 8*time.Second, "") + if err != nil { + return err + } + return postLarkNotification(ctx, client, parsed.String(), payload) +} + +func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("create Lark notification request: %w", err) + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-lark-notification/1") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err)) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + closeErr := response.Body.Close() + if readErr != nil { + return fmt.Errorf("read Lark response: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("close Lark response: %w", closeErr) + } + if err := validateLarkResponse(response.StatusCode, body); err != nil { + return err + } + return nil +} + +func sanitizeLarkRequestError(err error) error { + var requestErr *url.Error + if errors.As(err, &requestErr) && requestErr.Err != nil { + return requestErr.Err + } + return err +} + +func sendLarkNotificationTest(ctx context.Context, config map[string]any) error { + return sendLarkNotification(ctx, config, larkTestValues(time.Now())) +} diff --git a/internal/server/lark_notification_test.go b/internal/server/lark_notification_test.go new file mode 100644 index 0000000..dfb2be7 --- /dev/null +++ b/internal/server/lark_notification_test.go @@ -0,0 +1,204 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) { + payload, err := renderLarkPayload( + `{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`, + larkTemplateValues{ + "message": "quote: \"\nline", + "number": "+447386", + }, + ) + if err != nil { + t.Fatal(err) + } + if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want { + t.Fatalf("payload = %s, want %s", got, want) + } +} + +func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) { + payload, err := renderLarkPayload( + `{"msg_type":"text","content":{"text":{{message}}}}`, + larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"}, + ) + if err != nil { + t.Fatal(err) + } + if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want { + t.Fatalf("payload = %s, want %s", got, want) + } +} + +func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) { + for _, template := range []string{ + `{"text":{{unknown}}}`, + `[]`, + `{"msg_type":"text"`, + `{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`, + } { + t.Run(template[:min(len(template), 40)], func(t *testing.T) { + if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil { + t.Fatalf("template was accepted") + } + }) + } +} + +func TestSignLarkPayload(t *testing.T) { + const timestamp = int64(1_599_360_473) + if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want { + t.Fatalf("signature = %q, want %q", got, want) + } + + unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`) + signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0)) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(signed, &payload); err != nil { + t.Fatal(err) + } + if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" { + t.Fatalf("signed payload = %#v", payload) + } + + untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0)) + if err != nil || string(untouched) != string(unsigned) { + t.Fatalf("unsigned payload = %s, err = %v", untouched, err) + } +} + +func TestValidateLarkResponse(t *testing.T) { + for _, body := range []string{ + `{"code":0,"msg":"success"}`, + `{"StatusCode":0,"StatusMessage":"success"}`, + } { + if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil { + t.Fatalf("successful response %s = %v", body, err) + } + } + for _, response := range []struct { + status int + body string + }{ + {http.StatusBadGateway, `{"code":0}`}, + {http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`}, + {http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`}, + {http.StatusOK, `{}`}, + {http.StatusOK, `not-json`}, + } { + if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) { + t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err) + } + } +} + +func TestPostLarkNotificationSendsJSONPayload(t *testing.T) { + payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`) + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" { + t.Errorf("User-Agent = %q", got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + if string(body) != string(payload) { + t.Errorf("body = %s, want %s", body, payload) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"code":0,"msg":"success"}`) + })) + t.Cleanup(provider.Close) + + if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil { + t.Fatalf("postLarkNotification() = %v", err) + } +} + +func TestParseLarkWebhookURL(t *testing.T) { + for _, raw := range []string{ + "https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token", + "https://open.larksuite.com/open-apis/bot/v2/hook/lark-token", + "https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token", + } { + if _, err := parseLarkWebhookURL(raw); err != nil { + t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err) + } + } + for _, raw := range []string{ + "http://open.larksuite.com/open-apis/bot/v2/hook/token", + "https://example.com/open-apis/bot/v2/hook/token", + "https://open.larksuite.com/open-apis/bot/hook/token", + "https://open.larksuite.com/open-apis/bot/v2/hook/", + "https://open.larksuite.com/open-apis/bot/v2/hook/token/extra", + "https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1", + } { + if _, err := parseLarkWebhookURL(raw); err == nil { + t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw) + } + } +} + +func TestValidateLarkNotificationConfig(t *testing.T) { + valid := map[string]any{ + "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token", + "signing_enabled": true, + "secret": "demo", + "payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`, + } + if err := validateLarkNotificationConfig(valid); err != nil { + t.Fatalf("valid config = %v", err) + } + unsigned := map[string]any{ + "url": valid["url"], + "signing_enabled": false, + "payload_template": valid["payload_template"], + } + if err := validateLarkNotificationConfig(unsigned); err != nil { + t.Fatalf("unsigned config = %v", err) + } + if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" { + t.Fatalf("disabled signing secret = %q", secret) + } + if secret := larkSigningSecret(valid); secret != "demo" { + t.Fatalf("enabled signing secret = %q", secret) + } + for _, config := range []map[string]any{ + {"payload_template": valid["payload_template"]}, + {"url": valid["url"]}, + {"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]}, + } { + if err := validateLarkNotificationConfig(config); err == nil { + t.Fatalf("invalid config was accepted: %#v", config) + } + } +} + +func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) { + const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token" + err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")}) + if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" { + t.Fatalf("sanitized error = %q", err) + } +} diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index a4fab21..a8aa2b6 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -41,6 +41,7 @@ var notificationChannels = []string{ "bark", "pushplus", "wecom", + "lark", } var notificationFields = map[string]map[string]string{ @@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{ "wecom": { "urls": "strings", "payload_template": "string", }, + "lark": { + "url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string", + }, } // routeSettingsAPI is intentionally independent of the main router so it can @@ -240,6 +244,20 @@ func decodeNotificationConfig( if err != nil { return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err) } + if enabled && channel == "lark" { + var resolved map[string]any + if err := json.Unmarshal(config, &resolved); err != nil { + return false, nil, fmt.Errorf("decode lark notification config: %w", err) + } + signingEnabled, _ := resolved["signing_enabled"].(bool) + if signingEnabled && configString(resolved, "url") != store.SecretMask && + configString(resolved, "secret") == store.SecretMask { + return false, nil, errors.New("lark.secret must be re-entered when lark.url changes") + } + if err := validateLarkNotificationConfig(resolved); err != nil { + return false, nil, err + } + } return enabled, config, nil } @@ -265,6 +283,9 @@ func validateNotificationField( if name == "text_template" { limit = 32768 } + if channel == "lark" && name == "payload_template" { + limit = maxLarkPayloadBytes + } if len(value) > limit || strings.ContainsAny(value, "\x00") { return fmt.Errorf("%s is too long or contains invalid characters", field) } @@ -300,6 +321,16 @@ func validateNotificationField( return fmt.Errorf("%s is not a valid JSON template: %w", field, err) } } + if channel == "lark" && name == "payload_template" && value != "" { + if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil { + return fmt.Errorf("%s is not a valid JSON template: %w", field, err) + } + } + if channel == "lark" && name == "url" && value != "" && value != store.SecretMask { + if _, err := parseLarkWebhookURL(value); err != nil { + return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err) + } + } case "integer": var value int if err := json.Unmarshal(raw, &value); err != nil { @@ -387,7 +418,7 @@ func (s *Server) handleNotificationTest( writeError(w, http.StatusNotFound, "not_found", "notification channel was not found") return } - if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" { + if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" { writeError( w, http.StatusNotImplemented, @@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest( err = sendBarkNotificationTest(notificationContext, resolved) case "wecom": err = sendWecomNotificationTest(notificationContext, resolved) + case "lark": + err = sendLarkNotificationTest(notificationContext, resolved) } if err != nil { redacted := store.RedactText(err.Error(), provider) @@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig( // mergeNotificationTestSecretValue preserves masked values submitted by the // settings form while allowing newly entered sensitive values in the same -// request. WeCom URLs are a sensitive list, unlike the string-based secrets -// used by the other notification channels. +// request. Provider webhook URLs can be sensitive lists, unlike the +// string-based secrets used by the other notification channels. func mergeNotificationTestSecretValue(incoming, existing any) any { if incoming == nil { return existing @@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error } case "wecom": return validateWecomNotificationConfig(config) + case "lark": + return validateLarkNotificationConfig(config) case "telegram": token := configString(config, "bot_token") if token == "" || token == store.SecretMask { diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 61e91fd..858ead0 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m return response } -func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) { +func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) { test := newSettingsAPITest(t) recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "") if recorder.Code != http.StatusOK { @@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) { } } +func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) { + test := newSettingsAPITest(t) + webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token" + secret := "lark-signing-secret" + template := `{"msg_type":"text","content":{"text":{{message}}}}` + first, err := json.Marshal(map[string]any{ + "lark": map[string]any{ + "enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "payload_template": template, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first)) + if recorder.Code != http.StatusOK { + t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + if bytes.Contains(recorder.Body.Bytes(), []byte("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) { + t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + lark := response["data"].(map[string]any)["lark"].(map[string]any) + if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask { + t.Fatalf("redacted Lark config = %#v", lark) + } + + second, err := json.Marshal(map[string]any{ + "lark": map[string]any{ + "enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": store.SecretMask, "payload_template": template, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second)) + if recorder.Code != http.StatusOK { + t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err := test.database.NotificationSetting(context.Background(), "lark") + if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) { + t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err) + } +} + +func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) { + test := newSettingsAPITest(t) + template := `{"msg_type":"text","content":{"text":{{message}}}}` + body, err := json.Marshal(map[string]any{ + "lark": map[string]any{ + "enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token", + "signing_enabled": false, "payload_template": template, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body)) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + lark := response["data"].(map[string]any)["lark"].(map[string]any) + if _, exists := lark["secret"]; exists { + t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark) + } +} + func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) { test := newSettingsAPITest(t) raw, err := json.Marshal(map[string]any{ @@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t * } } +func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) { + test := newSettingsAPITest(t) + storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored" + storedConfig, err := json.Marshal(map[string]any{ + "url": storedURL, + "signing_enabled": true, + "secret": "stored-signing-secret", + "payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`, + }) + if err != nil { + t.Fatal(err) + } + if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{ + Channel: "lark", + Config: storedConfig, + }); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{ + "url": store.SecretMask, + "signing_enabled": true, + "secret": store.SecretMask, + "payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`, + }) + if err != nil { + t.Fatal(err) + } + resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw) + if err != nil { + t.Fatal(err) + } + if resolved["url"] != storedURL { + t.Fatalf("resolved URL = %#v", resolved["url"]) + } + if resolved["secret"] != "stored-signing-secret" { + t.Fatalf("resolved secret = %#v", resolved["secret"]) + } +} + func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { test := newSettingsAPITest(t) cases := []struct { @@ -284,6 +390,36 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`, code: "invalid_notification_config", }, + { + name: "invalid Lark payload template", + body: `{"lark":{"enabled":true,"payload_template":"[]"}}`, + code: "invalid_notification_config", + }, + { + name: "enabled Lark config without webhook URL", + body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`, + code: "invalid_notification_config", + }, + { + name: "enabled Lark signing without secret", + body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`, + code: "invalid_notification_config", + }, + { + name: "changed Lark URL with masked signing secret", + body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`, + code: "invalid_notification_config", + }, + { + name: "insecure Lark group bot URL", + body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`, + code: "invalid_notification_config", + }, + { + name: "non-Lark group bot URL", + body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`, + code: "invalid_notification_config", + }, { name: "null body", body: `null`, diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go index 84ee730..d3acffa 100644 --- a/internal/server/sms_notifications.go +++ b/internal/server/sms_notifications.go @@ -24,7 +24,7 @@ import ( const smsNotificationPollInterval = 2 * time.Second -var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"} +var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"} type smsNotification struct { DeviceID string @@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map func validateSMSNotificationConfig(channel string, config map[string]any) error { switch channel { - case "bark", "email", "webhook", "wecom": + case "bark", "email", "webhook", "wecom", "lark": if err := validateNotificationTestConfig(channel, config); err != nil { return err } @@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string] return sendWebhookSMSNotification(ctx, config, message) case "wecom": return sendWecomNotification(ctx, config, wecomSMSValues(message)) + case "lark": + return sendLarkNotification(ctx, config, larkSMSValues(message)) default: return fmt.Errorf("unsupported SMS notification channel %q", channel) } diff --git a/internal/server/sms_notifications_test.go b/internal/server/sms_notifications_test.go index 020b53a..90d733f 100644 --- a/internal/server/sms_notifications_test.go +++ b/internal/server/sms_notifications_test.go @@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) { } } +func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) { + message := smsNotification{ + DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", + Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello", + } + smsValues := larkSMSValues(message) + if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" || + smsValues["message"] != message.Text() || smsValues["content"] != "hello" || + smsValues["device_label"] != "EC20" { + t.Fatalf("Lark SMS values = %#v", smsValues) + } + + taskValues := larkAutomaticTaskValues(automaticTaskNotification{ + Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0), + }) + if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" { + t.Fatalf("Lark automatic task values = %#v", taskValues) + } + for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} { + if taskValues[name] != "" { + t.Fatalf("%s = %q, want empty", name, taskValues[name]) + } + } +} + func TestValidateSMSNotificationConfig(t *testing.T) { valid := map[string]map[string]any{ "bark": {"urls": []any{"https://api.day.app/key"}}, @@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) { "urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"}, "payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`, }, + "lark": { + "url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret", + "signing_enabled": true, + "secret": "signing-secret", + "payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`, + }, } for channel, config := range valid { if err := validateSMSNotificationConfig(channel, config); err != nil { diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 14cd68e..79b5861 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) { } } +func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token" + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "lark", Enabled: true, + Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`), + }); err != nil { + t.Fatal(err) + } + setting, err := database.NotificationSetting(ctx, "lark") + if err != nil { + t.Fatal(err) + } + var redacted map[string]any + if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil { + t.Fatal(err) + } + if redacted["url"] != SecretMask || redacted["secret"] != SecretMask { + t.Fatalf("redacted Lark config = %#v", redacted) + } + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "lark", Enabled: true, + Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`), + }); err != nil { + t.Fatal(err) + } + setting, err = database.NotificationSetting(ctx, "lark") + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) { + t.Fatalf("stored Lark config = %s", setting.Config) + } +} + +func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) { + setting := NotificationSetting{ + Config: json.RawMessage(`{"url":"","secret":""}`), + SensitiveFields: []string{"url", "secret"}, + } + var redacted map[string]any + if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil { + t.Fatal(err) + } + if redacted["url"] != "" || redacted["secret"] != "" { + t.Fatalf("empty sensitive values were masked: %#v", redacted) + } +} + func TestEventsPoliciesAndTraffic(t *testing.T) { ctx := context.Background() database := openTestStore(t, ":memory:") diff --git a/internal/store/models.go b/internal/store/models.go index b4c5f21..e58c4d2 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -610,6 +610,9 @@ func mergeJSONSecrets( func redactJSONValue(value any, replacement string) any { switch typed := value.(type) { case string: + if typed == "" { + return "" + } return replacement case []any: result := make([]any, len(typed)) diff --git a/internal/store/settings.go b/internal/store/settings.go index 0c495fb..faf52b3 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string { return []string{"token"} case "wecom": return []string{"urls"} + case "lark": + return []string{"url", "secret"} default: return nil } diff --git a/web/src/components/settings/BotTabs.tsx b/web/src/components/settings/BotTabs.tsx index 55c0e48..41f3c21 100644 --- a/web/src/components/settings/BotTabs.tsx +++ b/web/src/components/settings/BotTabs.tsx @@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps) { onChange({ enabled })} />
- {t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")} + {t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} /> diff --git a/web/src/components/settings/PushTabs.tsx b/web/src/components/settings/PushTabs.tsx index 760b290..9a5cbff 100644 --- a/web/src/components/settings/PushTabs.tsx +++ b/web/src/components/settings/PushTabs.tsx @@ -6,7 +6,7 @@ import { Select } from "../ui/Select"; import { Switch } from "../ui/Switch"; import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls"; import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model"; -import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model"; +import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model"; const HEADER_LIST_ID = "vocat-webhook-header-names"; @@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean { return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0); } -function SMSOnlyHint() { +function OneWayNotificationHint() { const { t } = useI18n(); return (
- {t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")} + {t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
); } @@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps } /> - +
} /> - +
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp } /> - +
} /> - +
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")} @@ -300,7 +300,81 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps< label={t("JSON 请求体模板")} hint={ <> - {t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} {"{{message}}"}{lang === "zh" ? "。" : "."} + {t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} {"{{message}}"}{lang === "zh" ? "。" : ". "} + {t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")} + + } + > +