additional fetaure buildout for footer and footer links
This commit is contained in:
@@ -63,6 +63,7 @@ func main() {
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
|
||||
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||
supportHandler := handlers.NewSupportHandler(cfg)
|
||||
|
||||
authenticate := middleware.Authenticate(userModel)
|
||||
requireSub := middleware.RequireSubscription(userModel)
|
||||
@@ -101,6 +102,10 @@ func main() {
|
||||
mux.Handle("GET "+b+"/user/account",
|
||||
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||
|
||||
// Support (auth required; avoids anonymous spam)
|
||||
mux.Handle("POST "+b+"/support",
|
||||
authenticate(http.HandlerFunc(supportHandler.Submit)))
|
||||
|
||||
// Authenticated + subscribed routes — addresses
|
||||
mux.Handle("POST "+b+"/addresses",
|
||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//loads environment-based configuration.
|
||||
// loads environment-based configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -9,65 +9,67 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 3001
|
||||
defaultDBPort = 5432
|
||||
defaultPollIntervalMS = 60000
|
||||
minPollIntervalMS = 1000
|
||||
defaultPort = 3001
|
||||
defaultDBPort = 5432
|
||||
defaultPollIntervalMS = 60000
|
||||
minPollIntervalMS = 1000
|
||||
defaultDigestIntervalHours = 24
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
APIBasePath string
|
||||
DatabaseURL string
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
FirebaseProjectID string
|
||||
EthRPCURL string
|
||||
PollIntervalMS int
|
||||
NodeEnv string
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
DigestIntervalHours int
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDPremium string
|
||||
StripePriceIDPro string
|
||||
StripePriceIDPremiumAnnual string
|
||||
StripePriceIDProAnnual string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
Port int
|
||||
APIBasePath string
|
||||
DatabaseURL string
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
FirebaseProjectID string
|
||||
EthRPCURL string
|
||||
PollIntervalMS int
|
||||
NodeEnv string
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
SupportInboxEmail string
|
||||
DigestIntervalHours int
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceIDPremium string
|
||||
StripePriceIDPro string
|
||||
StripePriceIDPremiumAnnual string
|
||||
StripePriceIDProAnnual string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("PORT", defaultPort),
|
||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||
DBUser: os.Getenv("DB_USER"),
|
||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||
DBName: os.Getenv("DB_NAME"),
|
||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
||||
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
Port: getEnvInt("PORT", defaultPort),
|
||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||
DBUser: os.Getenv("DB_USER"),
|
||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||
DBName: os.Getenv("DB_NAME"),
|
||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
||||
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||
|
||||
111
backend/internal/handlers/support.go
Normal file
111
backend/internal/handlers/support.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSupportDescriptionLen = 8000
|
||||
maxSupportEmailLen = 320
|
||||
)
|
||||
|
||||
var descriptionAllowedRE = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`)
|
||||
|
||||
type supportRequestBody struct {
|
||||
Email string `json:"email"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SupportHandler accepts authenticated support form submissions and emails the inbox.
|
||||
type SupportHandler struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewSupportHandler(cfg *config.Config) *SupportHandler {
|
||||
return &SupportHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// Submit handles POST /support.
|
||||
func (h *SupportHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||
var body supportRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_JSON", "Request body must be JSON")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(body.Email)
|
||||
description := strings.TrimSpace(body.Description)
|
||||
|
||||
if len(email) > maxSupportEmailLen {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Email is too long")
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Address == "" {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Invalid email address")
|
||||
return
|
||||
}
|
||||
|
||||
canonicalEmail := parsed.Address
|
||||
|
||||
if description == "" {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(description) > maxSupportDescriptionLen {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is too long")
|
||||
return
|
||||
}
|
||||
|
||||
if !descriptionAllowedRE.MatchString(description) {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION",
|
||||
"Description may only contain letters, numbers, and whitespace")
|
||||
return
|
||||
}
|
||||
|
||||
subject := time.Now().UTC().Format(time.RFC3339) + " - new support issue - koinp.ing"
|
||||
plainBody := "Contact email: " + canonicalEmail + "\n\nIssue description:\n" + description
|
||||
|
||||
// Local dev: skip Resend when SUPPORT_DEV_SKIP_EMAIL=1 (see backend logs for payload).
|
||||
if strings.EqualFold(h.cfg.NodeEnv, "development") && os.Getenv("SUPPORT_DEV_SKIP_EMAIL") == "1" {
|
||||
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL: skipping Resend; to=%s subject=%s", h.cfg.SupportInboxEmail, subject)
|
||||
log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL body:\n%s", plainBody)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.cfg.ResendAPIKey == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "EMAIL_UNAVAILABLE", "Support email is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := notifications.SendSupportEmail(
|
||||
h.cfg.ResendAPIKey,
|
||||
h.cfg.EmailFrom,
|
||||
h.cfg.SupportInboxEmail,
|
||||
subject,
|
||||
plainBody,
|
||||
); err != nil {
|
||||
log.Printf("support Submit: send email: %v", err)
|
||||
msg := "Could not send email. Confirm RESEND_API_KEY and that EMAIL_FROM uses a domain verified in Resend."
|
||||
if strings.EqualFold(h.cfg.NodeEnv, "development") {
|
||||
msg = "Email send failed (development): " + err.Error()
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "SEND_FAILED", msg)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string {
|
||||
return "Alert"
|
||||
}
|
||||
}
|
||||
|
||||
type resendSupportPayload struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// SendSupportEmail delivers a plain-text support request via Resend (HTML copy is escaped).
|
||||
func SendSupportEmail(apiKey, fromAddress, toAddress, subject, plainBody string) error {
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("RESEND_API_KEY not set") //nolint:err113
|
||||
}
|
||||
|
||||
escaped := html.EscapeString(plainBody)
|
||||
htmlBody := `<pre style="font-family:ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap;">` +
|
||||
escaped + `</pre>`
|
||||
|
||||
payload := resendSupportPayload{
|
||||
From: fromAddress,
|
||||
To: toAddress,
|
||||
Subject: subject,
|
||||
Text: plainBody,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal support email payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create support email request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := emailHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send support email: %v", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
log.Printf("Resend support email failed: HTTP %d body: %s", resp.StatusCode, string(snippet))
|
||||
return fmt.Errorf("resend API failed: HTTP %d: %s", resp.StatusCode, string(snippet)) //nolint:err113
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user