mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
chore(version): bump to 0.122.47
- refactor(turn): extract decodeTURNConfig for testability - feat(turn): add stealth domain fields to config - fix(apns): nest custom data under "body" for expo-notifications compatibility
This commit is contained in:
parent
f4c58db710
commit
cd8c717363
@ -39,19 +39,6 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type yamlCfg struct {
|
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
|
||||||
TURNSListenAddr string `yaml:"turns_listen_addr"`
|
|
||||||
PublicIP string `yaml:"public_ip"`
|
|
||||||
Realm string `yaml:"realm"`
|
|
||||||
AuthSecret string `yaml:"auth_secret"`
|
|
||||||
RelayPortStart int `yaml:"relay_port_start"`
|
|
||||||
RelayPortEnd int `yaml:"relay_port_end"`
|
|
||||||
Namespace string `yaml:"namespace"`
|
|
||||||
TLSCertPath string `yaml:"tls_cert_path"`
|
|
||||||
TLSKeyPath string `yaml:"tls_key_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ComponentError(logging.ComponentTURN, "Config file not found",
|
logger.ComponentError(logging.ComponentTURN, "Config file not found",
|
||||||
@ -60,26 +47,13 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var y yamlCfg
|
cfg, err := decodeTURNConfig(data)
|
||||||
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
|
if err != nil {
|
||||||
logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err))
|
logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err))
|
||||||
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &turn.Config{
|
|
||||||
ListenAddr: y.ListenAddr,
|
|
||||||
TURNSListenAddr: y.TURNSListenAddr,
|
|
||||||
PublicIP: y.PublicIP,
|
|
||||||
Realm: y.Realm,
|
|
||||||
AuthSecret: y.AuthSecret,
|
|
||||||
RelayPortStart: y.RelayPortStart,
|
|
||||||
RelayPortEnd: y.RelayPortEnd,
|
|
||||||
Namespace: y.Namespace,
|
|
||||||
TLSCertPath: y.TLSCertPath,
|
|
||||||
TLSKeyPath: y.TLSKeyPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs := cfg.Validate(); len(errs) > 0 {
|
if errs := cfg.Validate(); len(errs) > 0 {
|
||||||
fmt.Fprintf(os.Stderr, "\nTURN configuration errors (%d):\n", len(errs))
|
fmt.Fprintf(os.Stderr, "\nTURN configuration errors (%d):\n", len(errs))
|
||||||
for _, e := range errs {
|
for _, e := range errs {
|
||||||
@ -98,3 +72,50 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
|
|||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeTURNConfig strictly decodes the TURN YAML the namespace spawner writes
|
||||||
|
// (yaml.Marshal of turn.Config) into a turn.Config. The yamlCfg struct MUST
|
||||||
|
// carry every yaml-tagged field turn.Config marshals — DecodeStrict rejects
|
||||||
|
// unknown keys, so a missing field crashes the TURN binary at startup.
|
||||||
|
// Extracted (no os.Exit) so the spawner-output ↔ parser contract is unit-
|
||||||
|
// testable (see config_test.go).
|
||||||
|
func decodeTURNConfig(data []byte) (*turn.Config, error) {
|
||||||
|
type yamlCfg struct {
|
||||||
|
ListenAddr string `yaml:"listen_addr"`
|
||||||
|
TURNSListenAddr string `yaml:"turns_listen_addr"`
|
||||||
|
PublicIP string `yaml:"public_ip"`
|
||||||
|
Realm string `yaml:"realm"`
|
||||||
|
AuthSecret string `yaml:"auth_secret"`
|
||||||
|
RelayPortStart int `yaml:"relay_port_start"`
|
||||||
|
RelayPortEnd int `yaml:"relay_port_end"`
|
||||||
|
Namespace string `yaml:"namespace"`
|
||||||
|
TLSCertPath string `yaml:"tls_cert_path"`
|
||||||
|
TLSKeyPath string `yaml:"tls_key_path"`
|
||||||
|
// feat-124 stealth TURNS-over-:443: second cert served by SNI.
|
||||||
|
StealthDomain string `yaml:"stealth_domain"`
|
||||||
|
TLSStealthCertPath string `yaml:"tls_stealth_cert_path"`
|
||||||
|
TLSStealthKeyPath string `yaml:"tls_stealth_key_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var y yamlCfg
|
||||||
|
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &turn.Config{
|
||||||
|
ListenAddr: y.ListenAddr,
|
||||||
|
TURNSListenAddr: y.TURNSListenAddr,
|
||||||
|
PublicIP: y.PublicIP,
|
||||||
|
Realm: y.Realm,
|
||||||
|
AuthSecret: y.AuthSecret,
|
||||||
|
RelayPortStart: y.RelayPortStart,
|
||||||
|
RelayPortEnd: y.RelayPortEnd,
|
||||||
|
Namespace: y.Namespace,
|
||||||
|
TLSCertPath: y.TLSCertPath,
|
||||||
|
TLSKeyPath: y.TLSKeyPath,
|
||||||
|
|
||||||
|
StealthDomain: y.StealthDomain,
|
||||||
|
TLSStealthCertPath: y.TLSStealthCertPath,
|
||||||
|
TLSStealthKeyPath: y.TLSStealthKeyPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
60
core/cmd/turn/config_test.go
Normal file
60
core/cmd/turn/config_test.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/turn"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDecodeTURNConfig_acceptsSpawnerOutput is the regression guard for the
|
||||||
|
// feat-124 crash: the namespace spawner writes the TURN config via
|
||||||
|
// yaml.Marshal(turn.Config), and the TURN binary parses it with a STRICT
|
||||||
|
// decoder. If turn.Config gains a yaml field the parser doesn't know, strict
|
||||||
|
// decode rejects it and TURN crash-loops at startup. This pins that the
|
||||||
|
// spawner's exact output round-trips through the parser, including the stealth
|
||||||
|
// fields.
|
||||||
|
func TestDecodeTURNConfig_acceptsSpawnerOutput(t *testing.T) {
|
||||||
|
src := turn.Config{
|
||||||
|
ListenAddr: "0.0.0.0:3478",
|
||||||
|
TURNSListenAddr: "0.0.0.0:5349",
|
||||||
|
PublicIP: "203.0.113.7",
|
||||||
|
Realm: "orama-devnet.network",
|
||||||
|
AuthSecret: "secret",
|
||||||
|
RelayPortStart: 49152,
|
||||||
|
RelayPortEnd: 49951,
|
||||||
|
Namespace: "anchat-test",
|
||||||
|
TLSCertPath: "/x/turn-cert.pem",
|
||||||
|
TLSKeyPath: "/x/turn-key.pem",
|
||||||
|
StealthDomain: "cdn-3259254d4d3e.orama-devnet.network",
|
||||||
|
TLSStealthCertPath: "/var/lib/caddy/caddy/certificates/.../wildcard_.orama-devnet.network.crt",
|
||||||
|
TLSStealthKeyPath: "/var/lib/caddy/caddy/certificates/.../wildcard_.orama-devnet.network.key",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := decodeTURNConfig(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("strict decode of spawner output failed — TURN would crash-loop at startup: %v\n---\n%s", err, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.StealthDomain != src.StealthDomain ||
|
||||||
|
got.TLSStealthCertPath != src.TLSStealthCertPath ||
|
||||||
|
got.TLSStealthKeyPath != src.TLSStealthKeyPath {
|
||||||
|
t.Errorf("stealth fields did not round-trip: got %+v", got)
|
||||||
|
}
|
||||||
|
if got.AuthSecret != src.AuthSecret || got.TURNSListenAddr != src.TURNSListenAddr {
|
||||||
|
t.Errorf("core fields did not round-trip: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecodeTURNConfig_rejectsUnknownField confirms the strict decoder still
|
||||||
|
// rejects genuinely-unknown keys (so the contract above is meaningful).
|
||||||
|
func TestDecodeTURNConfig_rejectsUnknownField(t *testing.T) {
|
||||||
|
if _, err := decodeTURNConfig([]byte("listen_addr: \"0.0.0.0:3478\"\nbogus_field: 1\n")); err == nil {
|
||||||
|
t.Fatal("expected strict decode to reject an unknown field")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -149,7 +149,7 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error {
|
|||||||
if p.kind != KindVoIP && !hasVisibleContent(msg) {
|
if p.kind != KindVoIP && !hasVisibleContent(msg) {
|
||||||
return push.ErrEmptyContent
|
return push.ErrEmptyContent
|
||||||
}
|
}
|
||||||
payload, err := buildAPSPayload(msg)
|
payload, err := buildAPSPayload(msg, p.kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("apns: build payload: %w", err)
|
return fmt.Errorf("apns: build payload: %w", err)
|
||||||
}
|
}
|
||||||
@ -281,12 +281,25 @@ func tokenPrefix(token string) string {
|
|||||||
return token[:8] + "..."
|
return token[:8] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAPSPayload assembles the APNs JSON payload from a generic
|
// buildAPSPayload assembles the APNs JSON payload from a generic PushMessage.
|
||||||
// PushMessage. The `aps` dictionary is the Apple-required wrapper;
|
// The `aps` dictionary is the Apple-required wrapper; custom `Data` placement
|
||||||
// custom fields (`data`) go alongside at the top level.
|
// depends on the kind:
|
||||||
|
//
|
||||||
|
// - KindAlert: custom data is nested under a top-level "body" object.
|
||||||
|
// expo-notifications' iOS serializer sets content.data ONLY from
|
||||||
|
// userInfo["body"] for remote notifications (NotificationRecords.swift:
|
||||||
|
// `if isRemote { return userInfo["body"] }`) — top-level sibling keys of
|
||||||
|
// `aps` are IGNORED, so spreading them there yields content.data=null on
|
||||||
|
// iOS. This was bugboard #38 (Data never reached the JS client despite
|
||||||
|
// correct wire serialization). Note: "body" here is the data envelope
|
||||||
|
// expo expects; it is distinct from the human-readable alert body, which
|
||||||
|
// lives at aps.alert.body.
|
||||||
|
// - KindVoIP: custom data stays at the top level. PushKit/CallKit pushes are
|
||||||
|
// handled by the app's native pushRegistry (not expo-notifications), which
|
||||||
|
// reads payload.dictionaryPayload directly.
|
||||||
//
|
//
|
||||||
// Reference: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
|
// Reference: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification
|
||||||
func buildAPSPayload(msg push.PushMessage) ([]byte, error) {
|
func buildAPSPayload(msg push.PushMessage, kind Kind) ([]byte, error) {
|
||||||
alert := map[string]string{}
|
alert := map[string]string{}
|
||||||
if msg.Title != "" {
|
if msg.Title != "" {
|
||||||
alert["title"] = msg.Title
|
alert["title"] = msg.Title
|
||||||
@ -338,13 +351,28 @@ func buildAPSPayload(msg push.PushMessage) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
root := map[string]interface{}{"aps": aps}
|
root := map[string]interface{}{"aps": aps}
|
||||||
|
|
||||||
|
// Collect tenant custom data, excluding reserved keys: `aps` (must not be
|
||||||
|
// clobbered) and `content_available` (already mapped into aps above).
|
||||||
|
data := map[string]interface{}{}
|
||||||
for k, v := range msg.Data {
|
for k, v := range msg.Data {
|
||||||
// Don't allow tenant data to clobber `aps`, and skip the
|
|
||||||
// content_available marker since we mapped it to aps above.
|
|
||||||
if k == "aps" || k == "content_available" {
|
if k == "aps" || k == "content_available" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) > 0 {
|
||||||
|
if kind == KindVoIP {
|
||||||
|
// Native PushKit reads the dictionary payload directly — top-level.
|
||||||
|
for k, v := range data {
|
||||||
root[k] = v
|
root[k] = v
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// expo-notifications surfaces content.data from userInfo["body"]
|
||||||
|
// only (bugboard #38) — nest the data envelope there.
|
||||||
|
root["body"] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
return json.Marshal(root)
|
return json.Marshal(root)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -155,7 +155,7 @@ func TestValidator_RedactNeverEchoesP8Key(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildAPSPayload_basicAlert(t *testing.T) {
|
func TestBuildAPSPayload_basicAlert(t *testing.T) {
|
||||||
msg := push.PushMessage{Title: "hi", Body: "from orama"}
|
msg := push.PushMessage{Title: "hi", Body: "from orama"}
|
||||||
raw, err := buildAPSPayload(msg)
|
raw, err := buildAPSPayload(msg, KindAlert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build: %v", err)
|
t.Fatalf("build: %v", err)
|
||||||
}
|
}
|
||||||
@ -174,23 +174,58 @@ func TestBuildAPSPayload_basicAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildAPSPayload_dataAlongsideAPS(t *testing.T) {
|
// Bugboard #38: for an ALERT push, custom data must be nested under a
|
||||||
|
// top-level "body" object — expo-notifications' iOS serializer reads
|
||||||
|
// content.data from userInfo["body"] only, ignoring top-level sibling keys.
|
||||||
|
func TestBuildAPSPayload_alertNestsDataUnderBody(t *testing.T) {
|
||||||
msg := push.PushMessage{
|
msg := push.PushMessage{
|
||||||
Title: "x",
|
Title: "x",
|
||||||
Body: "y",
|
Body: "y",
|
||||||
Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"},
|
Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"},
|
||||||
}
|
}
|
||||||
raw, _ := buildAPSPayload(msg)
|
raw, _ := buildAPSPayload(msg, KindAlert)
|
||||||
var out map[string]interface{}
|
var out map[string]interface{}
|
||||||
_ = json.Unmarshal(raw, &out)
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("payload not valid JSON: %v", err)
|
||||||
|
}
|
||||||
if _, hasAPS := out["aps"]; !hasAPS {
|
if _, hasAPS := out["aps"]; !hasAPS {
|
||||||
t.Error("payload missing aps")
|
t.Error("payload missing aps")
|
||||||
}
|
}
|
||||||
if out["thread"] != "abc" {
|
// Must NOT be at the top level (expo would ignore it there).
|
||||||
t.Errorf("data.thread missing; got %v", out)
|
if _, leaked := out["thread"]; leaked {
|
||||||
|
t.Errorf("data leaked to top level; expo-notifications would drop it: %v", out)
|
||||||
}
|
}
|
||||||
if out["deeplink"] != "anchat://room/42" {
|
body, ok := out["body"].(map[string]interface{})
|
||||||
t.Errorf("data.deeplink missing; got %v", out)
|
if !ok {
|
||||||
|
t.Fatalf("alert data not nested under top-level \"body\" object; got %v", out)
|
||||||
|
}
|
||||||
|
if body["thread"] != "abc" || body["deeplink"] != "anchat://room/42" {
|
||||||
|
t.Errorf("body envelope missing data; got %v", body)
|
||||||
|
}
|
||||||
|
// The human-readable alert body stays under aps.alert.body, distinct from
|
||||||
|
// the data envelope key.
|
||||||
|
aps := out["aps"].(map[string]interface{})
|
||||||
|
if alert, ok := aps["alert"].(map[string]interface{}); !ok || alert["body"] != "y" {
|
||||||
|
t.Errorf("aps.alert.body should be the human-readable body; got %v", aps["alert"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoIP pushes are handled by native PushKit (not expo-notifications), so
|
||||||
|
// custom data stays at the top level of the dictionary payload.
|
||||||
|
func TestBuildAPSPayload_voipKeepsDataTopLevel(t *testing.T) {
|
||||||
|
msg := push.PushMessage{
|
||||||
|
Data: map[string]interface{}{"callId": "c-1", "callerName": "Alice"},
|
||||||
|
}
|
||||||
|
raw, _ := buildAPSPayload(msg, KindVoIP)
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("payload not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if out["callId"] != "c-1" || out["callerName"] != "Alice" {
|
||||||
|
t.Errorf("voip data must stay top-level for PushKit; got %v", out)
|
||||||
|
}
|
||||||
|
if _, nested := out["body"]; nested {
|
||||||
|
t.Errorf("voip data must NOT be nested under body; got %v", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,7 +234,7 @@ func TestBuildAPSPayload_dataCannotClobberAPS(t *testing.T) {
|
|||||||
Title: "x",
|
Title: "x",
|
||||||
Data: map[string]interface{}{"aps": "evil"},
|
Data: map[string]interface{}{"aps": "evil"},
|
||||||
}
|
}
|
||||||
raw, _ := buildAPSPayload(msg)
|
raw, _ := buildAPSPayload(msg, KindAlert)
|
||||||
var out map[string]interface{}
|
var out map[string]interface{}
|
||||||
_ = json.Unmarshal(raw, &out)
|
_ = json.Unmarshal(raw, &out)
|
||||||
apsField, ok := out["aps"]
|
apsField, ok := out["aps"]
|
||||||
@ -215,7 +250,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) {
|
|||||||
msg := push.PushMessage{
|
msg := push.PushMessage{
|
||||||
Title: "x", Badge: 3, Sound: "ding.caf",
|
Title: "x", Badge: 3, Sound: "ding.caf",
|
||||||
}
|
}
|
||||||
raw, _ := buildAPSPayload(msg)
|
raw, _ := buildAPSPayload(msg, KindAlert)
|
||||||
if !strings.Contains(string(raw), `"badge":3`) {
|
if !strings.Contains(string(raw), `"badge":3`) {
|
||||||
t.Errorf("badge not in payload: %s", raw)
|
t.Errorf("badge not in payload: %s", raw)
|
||||||
}
|
}
|
||||||
@ -226,7 +261,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) {
|
func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) {
|
||||||
msg := push.PushMessage{Title: "x", Channel: "messages"}
|
msg := push.PushMessage{Title: "x", Channel: "messages"}
|
||||||
raw, _ := buildAPSPayload(msg)
|
raw, _ := buildAPSPayload(msg, KindAlert)
|
||||||
if !strings.Contains(string(raw), `"thread-id":"messages"`) {
|
if !strings.Contains(string(raw), `"thread-id":"messages"`) {
|
||||||
t.Errorf("channel not mapped to thread-id: %s", raw)
|
t.Errorf("channel not mapped to thread-id: %s", raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,28 @@
|
|||||||
// Package ntfy implements a push.PushProvider backed by an ntfy server.
|
// Package ntfy implements a push.PushProvider backed by an ntfy server.
|
||||||
//
|
//
|
||||||
// ntfy delivers notifications via plain HTTP POST to <baseURL>/<topic>.
|
// ntfy delivers notifications via plain HTTP POST to <baseURL>/<topic>.
|
||||||
// We map PushMessage fields to ntfy headers:
|
// We map PushMessage fields to the ntfy publish surface:
|
||||||
// - Title -> "Title"
|
// - Title -> "Title" header
|
||||||
// - Priority -> "Priority"
|
// - Priority -> "Priority" header
|
||||||
// - Channel -> "Tags"
|
// - Channel -> "Tags" header
|
||||||
// - Data -> base64-encoded JSON in "X-Data"
|
// - Body -> the POST body (ntfy's "message", relayed verbatim)
|
||||||
|
// - Data -> the POST body as JSON, ONLY when Body is empty
|
||||||
//
|
//
|
||||||
// See https://docs.ntfy.sh/publish/#publish-as-json for details.
|
// IMPORTANT (bugboard #126): ntfy does NOT relay arbitrary `X-*` request
|
||||||
|
// headers into the subscriber stream — only its recognized publish headers
|
||||||
|
// (Title, Priority, Tags, Click, Actions, Attach, …) and the message body
|
||||||
|
// reach the client. So structured Data and a numeric Badge cannot be carried
|
||||||
|
// as custom headers; the only field a subscriber reliably receives besides
|
||||||
|
// title/priority/tags is the message BODY. We therefore deliver Data through
|
||||||
|
// the body (UnifiedPush convention: the body IS the payload). A caller that
|
||||||
|
// sets an explicit Body owns it — to ship structured data alongside a
|
||||||
|
// human-readable body, encode both into the Body envelope.
|
||||||
|
//
|
||||||
|
// See https://docs.ntfy.sh/publish/ for the recognized header set.
|
||||||
package ntfy
|
package ntfy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -79,7 +89,20 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(msg.Body))
|
// Determine the POST body — the only structured payload ntfy relays to
|
||||||
|
// subscribers (bugboard #126). A caller-supplied Body wins; otherwise, if
|
||||||
|
// there's structured Data, serialize it as the body so a data-only push
|
||||||
|
// still reaches the client (UnifiedPush convention: body == payload).
|
||||||
|
body := msg.Body
|
||||||
|
if body == "" && len(msg.Data) > 0 {
|
||||||
|
b, err := json.Marshal(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntfy: marshal data: %w", err)
|
||||||
|
}
|
||||||
|
body = string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ntfy: build request: %w", err)
|
return fmt.Errorf("ntfy: build request: %w", err)
|
||||||
}
|
}
|
||||||
@ -96,16 +119,10 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error {
|
|||||||
// ntfy uses "Tags" for both visual emoji and operator-defined tags.
|
// ntfy uses "Tags" for both visual emoji and operator-defined tags.
|
||||||
req.Header.Set("Tags", msg.Channel)
|
req.Header.Set("Tags", msg.Channel)
|
||||||
}
|
}
|
||||||
if msg.Badge > 0 {
|
// NOTE: Badge and arbitrary Data are intentionally NOT sent as custom
|
||||||
req.Header.Set("X-Badge", fmt.Sprintf("%d", msg.Badge))
|
// headers — ntfy does not relay `X-*` headers to subscribers (#126), so
|
||||||
}
|
// doing so silently drops them. Data rides the body (above); a badge
|
||||||
if len(msg.Data) > 0 {
|
// count, if needed, must be encoded into the body by the caller.
|
||||||
b, err := json.Marshal(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ntfy: marshal data: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("X-Data", base64.StdEncoding.EncodeToString(b))
|
|
||||||
}
|
|
||||||
if p.authToken != "" {
|
if p.authToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.authToken)
|
req.Header.Set("Authorization", "Bearer "+p.authToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package ntfy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -61,9 +60,14 @@ func TestSend_happy_path(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSend_includes_data_header_when_data_set(t *testing.T) {
|
// Bugboard #126: ntfy does not relay X-* headers to subscribers, so Data must
|
||||||
var gotData string
|
// ride the body. With no explicit Body, a data-only push serializes Data as
|
||||||
|
// the JSON body — and must NOT set the dead X-Data header.
|
||||||
|
func TestSend_dataOnly_ridesBody_noXDataHeader(t *testing.T) {
|
||||||
|
var gotBody, gotData string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
gotData = r.Header.Get("X-Data")
|
gotData = r.Header.Get("X-Data")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@ -72,39 +76,49 @@ func TestSend_includes_data_header_when_data_set(t *testing.T) {
|
|||||||
p := New(Config{BaseURL: srv.URL}, nil)
|
p := New(Config{BaseURL: srv.URL}, nil)
|
||||||
err := p.Send(context.Background(), push.PushMessage{
|
err := p.Send(context.Background(), push.PushMessage{
|
||||||
DeviceToken: "topic",
|
DeviceToken: "topic",
|
||||||
Body: "x",
|
|
||||||
Data: map[string]interface{}{"call_id": "abc-123"},
|
Data: map[string]interface{}{"call_id": "abc-123"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Send: %v", err)
|
t.Fatalf("Send: %v", err)
|
||||||
}
|
}
|
||||||
decoded, err := base64.StdEncoding.DecodeString(gotData)
|
if gotData != "" {
|
||||||
if err != nil {
|
t.Errorf("X-Data header must not be set (ntfy drops it); got %q", gotData)
|
||||||
t.Fatalf("X-Data not valid base64: %v", err)
|
|
||||||
}
|
}
|
||||||
var got map[string]interface{}
|
var got map[string]interface{}
|
||||||
if err := json.Unmarshal(decoded, &got); err != nil {
|
if err := json.Unmarshal([]byte(gotBody), &got); err != nil {
|
||||||
t.Fatalf("X-Data not valid JSON: %v", err)
|
t.Fatalf("data-only body not valid JSON: %v (body=%q)", err, gotBody)
|
||||||
}
|
}
|
||||||
if got["call_id"] != "abc-123" {
|
if got["call_id"] != "abc-123" {
|
||||||
t.Errorf("data round-trip failed: got %v", got)
|
t.Errorf("data did not ride the body: got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSend_no_data_no_data_header(t *testing.T) {
|
// An explicit Body wins — Data does NOT clobber a caller-supplied body (the
|
||||||
var gotData string
|
// caller owns the envelope; this is anchat's call-push pattern).
|
||||||
|
func TestSend_explicitBody_winsOverData(t *testing.T) {
|
||||||
|
var gotBody, gotData string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
gotData = r.Header.Get("X-Data")
|
gotData = r.Header.Get("X-Data")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
p := New(Config{BaseURL: srv.URL}, nil)
|
p := New(Config{BaseURL: srv.URL}, nil)
|
||||||
if err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Body: "x"}); err != nil {
|
err := p.Send(context.Background(), push.PushMessage{
|
||||||
t.Fatal(err)
|
DeviceToken: "topic",
|
||||||
|
Body: `{"type":"call.invite","callId":"c1"}`,
|
||||||
|
Data: map[string]interface{}{"ignored": "yes"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if gotBody != `{"type":"call.invite","callId":"c1"}` {
|
||||||
|
t.Errorf("explicit body not preserved; got %q", gotBody)
|
||||||
}
|
}
|
||||||
if gotData != "" {
|
if gotData != "" {
|
||||||
t.Errorf("expected no X-Data header, got %q", gotData)
|
t.Errorf("X-Data header must not be set; got %q", gotData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@debros/orama",
|
"name": "@debros/orama",
|
||||||
"version": "0.122.46",
|
"version": "0.122.47",
|
||||||
"description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more",
|
"description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user