package flare import ( "context" "encoding/json" "errors" "fmt" "regexp" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" ) // Returned after a command has printed why it failed, so the binary exits 1 // without repeating the message. var ( errGenerateFailed = errors.New(GenerateVAPIDKeysCommandName + ": failed") errTestPushFailed = errors.New(TestPushCommandName + ": failed") ) // Names of the push console commands. const ( GenerateVAPIDKeysCommandName = "websockets:generate-vapid-keys" TestPushCommandName = "websockets:test-push" ) var base64URLChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // Commands returns the Web Push console commands for an application // binary: GenerateVAPIDKeysCommandName and TestPushCommandName. An // application appends them to the commands its plugin returns from // Commands. func Commands(app *backpack.App) []bonfire.Command { return []bonfire.Command{generateVAPIDKeysCommand(app), testPushCommand(app)} } func flagSet(in bonfire.Input, name string) bool { v, ok := in.Flag(name) return ok && v != "false" } // generateVAPIDKeysCommand ports the WinterCMS GenerateVapidKeys command. // Configured keys are only ever shown truncated; a newly generated pair is // printed in full, because that is the command's purpose. func generateVAPIDKeysCommand(app *backpack.App) bonfire.Command { return bonfire.Command{ Name: GenerateVAPIDKeysCommandName, Description: "Generate VAPID keys for Web Push notifications", Flags: []bonfire.Flag{ {Name: "update", Bare: true, Description: "Save the new keys to the environment's overrides file"}, {Name: "show-current", Bare: true, Description: "Show the configured keys (truncated) and stop"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { out.Println("Web Push VAPID Key Generator") out.Println("============================") out.Println() showCurrentKeys(out, configOf(app)) if flagSet(in, "show-current") { return nil } out.Println() out.Println("Generating new VAPID keys...") keys, err := GenerateVAPIDKeys() if err != nil { out.Error("Failed to generate VAPID keys: " + err.Error()) return errGenerateFailed } out.Success("New keys generated successfully!") out.Println() if !validateNewKeys(out, keys) { return errGenerateFailed } out.Println() out.Println(fmt.Sprintf("Public Key (%d chars):", len(keys.PublicKey))) out.Println("──────────────────────") out.Println(keys.PublicKey) out.Println() out.Println(fmt.Sprintf("Private Key (%d chars):", len(keys.PrivateKey))) out.Println("──────────────────────") out.Println(keys.PrivateKey) out.Println() if flagSet(in, "update") { if err := saveKeys(app, keys); err != nil { out.Error("Failed to save the keys: " + err.Error()) return errGenerateFailed } out.Success(fmt.Sprintf("Keys saved to env/%s/overrides.yaml in the config directory", app.Config.Environment())) out.Println() } else { out.Println("To set the keys manually, add them to the application's environment:") out.Println() out.Println("SUMMER_PUSH__PUBLIC_KEY=" + keys.PublicKey) out.Println("SUMMER_PUSH__PRIVATE_KEY=" + keys.PrivateKey) out.Println() out.Println("Or run this command with --update to save them to the environment's overrides file.") out.Println() } out.Println("Next steps:") out.Println(" 1. Restart the application so it reads the new keys") out.Println(" 2. Browsers subscribed with the old public key must subscribe again") out.Println(" 3. Test a subscription over HTTPS (browsers refuse push on plain HTTP)") out.Println() out.Success("VAPID key generation completed") return nil }, } } func configOf(app *backpack.App) Config { if app == nil { return LoadConfig(nil) } return LoadConfig(app.Config) } // showCurrentKeys prints the configured keys as first 8 + "..." + last 4 // characters with their length, and a check mark when they decode to a // 65-byte public point and a 32-byte private scalar. func showCurrentKeys(out bonfire.Output, cfg Config) { out.Println("Current keys:") printKey := func(label, key string, wantBytes int) { if key == "" { out.Println(" " + label + "Not configured") return } mark := "✓" if raw, err := decodeBase64URL(key); err != nil || len(raw) != wantBytes { mark = "✗ INVALID" } out.Println(fmt.Sprintf(" %s%s (%d chars) %s", label, truncateKey(key), len(key), mark)) } printKey("Public: ", cfg.PublicKey, publicKeySize) printKey("Private: ", cfg.PrivateKey, 32) } // truncateKey keeps the first 8 and last 4 characters. A value too short // for that to hide anything is not shown at all. func truncateKey(s string) string { if len(s) < 20 { return "..." } return s[:8] + "..." + s[len(s)-4:] } func validateNewKeys(out bonfire.Output, keys VAPIDKeys) bool { out.Println("Keys validated:") ok := true if len(keys.PublicKey) == PublicKeyLength { out.Success(fmt.Sprintf("Public key: %d characters (correct)", len(keys.PublicKey))) } else { out.Error(fmt.Sprintf("Public key: %d characters (expected %d)", len(keys.PublicKey), PublicKeyLength)) ok = false } if len(keys.PrivateKey) == PrivateKeyLength { out.Success(fmt.Sprintf("Private key: %d characters (correct)", len(keys.PrivateKey))) } else { out.Error(fmt.Sprintf("Private key: %d characters (expected %d)", len(keys.PrivateKey), PrivateKeyLength)) ok = false } if base64URLChars.MatchString(keys.PublicKey) && base64URLChars.MatchString(keys.PrivateKey) { out.Success("Base64url encoding valid") } else { out.Error("Invalid base64url encoding") ok = false } if _, err := ParseVAPIDKeys(keys.PublicKey, keys.PrivateKey); err != nil { out.Error(err.Error()) ok = false } return ok } // saveKeys stores the pair as runtime overrides and persists them to the // environment's overrides file (mode 0600). func saveKeys(app *backpack.App, keys VAPIDKeys) error { if app == nil || app.Config == nil { return errors.New("no configuration loaded") } if err := app.Config.Set("push.public_key", keys.PublicKey); err != nil { return err } if err := app.Config.Set("push.private_key", keys.PrivateKey); err != nil { return err } return app.Config.Persist() } // testPushPayload is the test notification. Field order is the JSON order. type testPushPayload struct { Title string `json:"title"` Body string `json:"body"` Data testPushData `json:"data"` } type testPushData struct { Test bool `json:"test"` Timestamp int64 `json:"timestamp"` } // testPushCommand ports the WinterCMS TestPushNotifications command. The // subscriptions come from the flare.SubscriptionSource the application // publishes; without one the command stops. func testPushCommand(app *backpack.App) bonfire.Command { return bonfire.Command{ Name: TestPushCommandName, Description: "Test push notification system and send test notification", Args: []bonfire.Arg{{Name: "user_id", Description: "User ID to send test notification to", Required: true}}, Flags: []bonfire.Flag{ // Accepted for compatibility: the configuration is always shown. {Name: "show-config", Bare: true, Description: "Display current push configuration"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { out.Println("Push Notification Tester") out.Println("========================") out.Println() svc, err := From(app) if err != nil { out.Error(err.Error()) return errTestPushFailed } cfg := svc.Config() showPushConfig(out, cfg) rawID, _ := in.Argument("user_id") userID, err := strconv.ParseUint(strings.TrimSpace(rawID), 10, 64) if err != nil || userID == 0 || userID > uint64(^uint(0)) { out.Error(fmt.Sprintf("User %s not found", rawID)) return errTestPushFailed } src, ok := app.Lookup[SubscriptionSource]() if !ok || src == nil { out.Error("no subscription source registered") out.Println("The application must publish a flare.SubscriptionSource to read stored push subscriptions.") return errTestPushFailed } subs, err := src.Subscriptions(ctx, uint(userID)) if errors.Is(err, ErrUserNotFound) { out.Error(fmt.Sprintf("User %d not found", userID)) return errTestPushFailed } if err != nil { out.Error("Failed to read push subscriptions: " + err.Error()) return errTestPushFailed } out.Info(fmt.Sprintf("Testing push for user ID %d", userID)) out.Println() if len(subs) == 0 { out.Error("No push subscriptions found for this user") out.Println() out.Println("The user needs to:") out.Println(" 1. Visit the app in a browser") out.Println(" 2. Grant notification permission when prompted") out.Println(" 3. Wait for subscription to be saved") out.Println() out.Println("Debug steps:") out.Println(" - Check browser console for errors") out.Println(" - Ensure HTTPS is being used") out.Println(" - Verify service worker is registered") return errTestPushFailed } out.Success(fmt.Sprintf("Found %d subscription(s)", len(subs))) out.Println() now := time.Now() for _, s := range subs { out.Println(fmt.Sprintf("Subscription #%d:", s.ID)) out.Println(" Endpoint: " + endpointPreview(s.Endpoint)) out.Println(" User Agent: " + s.UserAgent) out.Println(" Subscribed: " + ago(s.SubscribedAt, now)) if s.LastUsedAt != nil { out.Println(" Last Used: " + ago(s.LastUsedAt, now)) } out.Println() } send, err := out.Confirm("Send test notification?", true) if err != nil { return err } if !send { return nil } if !cfg.Enabled { out.Error("Push is disabled (push.enabled is false); no notification was sent") return errTestPushFailed } return sendTestPush(ctx, out, svc, appName(app), subs) }, } } func showPushConfig(out bonfire.Output, cfg Config) { out.Println("Current Configuration:") if cfg.Enabled { out.Println(" Enabled: ✓ Yes") } else { out.Println(" Enabled: ✗ No") } setLine := func(label, v string) { if v == "" { out.Println(" " + label + ": ✗ Missing") return } out.Println(fmt.Sprintf(" %s: ✓ Set (%d chars)", label, len(v))) } setLine("Public Key", cfg.PublicKey) setLine("Private Key", cfg.PrivateKey) if cfg.Subject != "" { out.Println(" Subject: " + cfg.Subject) } else { out.Println(" Subject: Not set") } if cfg.Subject != "" && cfg.PublicKey != "" { out.Println() if n := len(cfg.PublicKey); n != 87 && n != 88 { out.Warning(fmt.Sprintf("Public key length unusual: %d chars (expected 87-88)", n)) } else { out.Success(fmt.Sprintf("Public key length valid: %d chars", n)) } if n := len(cfg.PrivateKey); n != PrivateKeyLength { out.Warning(fmt.Sprintf("Private key length unusual: %d chars (expected %d)", n, PrivateKeyLength)) } else { out.Success(fmt.Sprintf("Private key length valid: %d chars", n)) } switch { case strings.HasPrefix(cfg.Subject, "mailto:"): out.Success("Subject format: mailto (valid for production)") case strings.HasPrefix(cfg.Subject, "https://"): out.Success("Subject format: HTTPS URL (valid for testing)") default: out.Warning("Subject format unusual: should start with 'mailto:' or 'https://'") } } out.Println() } func sendTestPush(ctx context.Context, out bonfire.Output, svc *Service, name string, subs []SubscriptionInfo) error { now := time.Now() payload, err := json.Marshal(testPushPayload{ Title: name + " test", Body: "This is a test push notification sent at " + now.Format("15:04:05"), Data: testPushData{Test: true, Timestamp: now.Unix()}, }) if err != nil { return err } out.Info("Sending test notification...") pusher := svc.Pusher() failed := 0 for _, s := range subs { if err := pusher.Send(ctx, s.Subscription, payload, SendOptions{}); err != nil { failed++ msg := err.Error() if errors.Is(err, ErrSubscriptionGone) { msg += " (delete this subscription)" } out.Error(fmt.Sprintf("Subscription #%d: %s", s.ID, msg)) continue } out.Success(fmt.Sprintf("Subscription #%d: sent", s.ID)) } out.Println() if failed > 0 { out.Error(fmt.Sprintf("Failed to send %d of %d notification(s)", failed, len(subs))) out.Println() out.Println("Common causes:") out.Println(" - Invalid VAPID keys") out.Println(" - Subscription endpoint no longer valid") out.Println(" - Endpoint host not in push.allowed_hosts") out.Println(" - Push service unavailable or network connectivity issues") return errTestPushFailed } out.Success("Test notification sent successfully!") out.Println("Check the browser for the notification.") return nil } func appName(app *backpack.App) string { if app != nil && app.Config != nil { if n := strings.TrimSpace(app.Config.String("app.name")); n != "" { return n } } return "SummerCMS" } // endpointPreview is the first 60 characters of the endpoint and "...". func endpointPreview(endpoint string) string { if len(endpoint) > 60 { endpoint = endpoint[:60] } return endpoint + "..." } // ago renders t relative to now, as "3 days ago". func ago(t *time.Time, now time.Time) string { if t == nil || t.IsZero() { return "unknown" } d := now.Sub(*t) if d < 0 { return "just now" } unit := func(n int64, name string) string { if n == 1 { return "1 " + name + " ago" } return strconv.FormatInt(n, 10) + " " + name + "s ago" } switch { case d < time.Minute: return unit(int64(d/time.Second), "second") case d < time.Hour: return unit(int64(d/time.Minute), "minute") case d < 24*time.Hour: return unit(int64(d/time.Hour), "hour") case d < 7*24*time.Hour: return unit(int64(d/(24*time.Hour)), "day") case d < 30*24*time.Hour: return unit(int64(d/(7*24*time.Hour)), "week") case d < 365*24*time.Hour: return unit(int64(d/(30*24*time.Hour)), "month") default: return unit(int64(d/(365*24*time.Hour)), "year") } }