Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend visible to everyone. - Auth: Traefik forward-auth (trust X-Authentik-* headers only from trusted_proxies CIDRs, keyed by email, role synced from admin_group), keeping local password login as break-glass. New [auth] config, CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md. - Budget: count every Apify run (apify_api_usage table) and show calls + estimated cost to all users on the dashboard, with an optional monthly-budget bar. New [budget] config + settings. - Email: Resend client for opt-in deal alerts and a weekly digest (Mon 09:00). Per-user email + toggles on Settings. New [resend] config. - Defaults: new items default to a 12-hour poll interval to cut spend. users table gains email/auth_source/email-pref columns (migrated in place). go build/vet/test green; boots and migrates cleanly.
This commit is contained in:
@@ -63,11 +63,19 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
return templates.DashboardData{
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
ApifyToday: apifyToday,
|
||||
ApifyMonth: apifyMonth,
|
||||
ApifyCostPerCall: costPerCall,
|
||||
MonthlyBudget: monthlyBudget,
|
||||
EbayToday: ebayUsed,
|
||||
EbayLimit: ebayLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler {
|
||||
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
|
||||
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
|
||||
// it; Traefik must be configured to strip client-supplied values.
|
||||
// CaptureDirectIP must run before RealIP: forward-auth trusts identity
|
||||
// headers only from the real connecting peer, which RealIP would otherwise
|
||||
// overwrite with the proxy-supplied X-Forwarded-For.
|
||||
r.Use(auth.CaptureDirectIP)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
@@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||
r.Use(a.Auth.LoadUser)
|
||||
// ForwardAuth runs after LoadUser so a trusted proxy's identity headers
|
||||
// supersede any stale local session. No-op when forward-auth is off or
|
||||
// the peer is untrusted.
|
||||
r.Use(a.Auth.ForwardAuth)
|
||||
r.Use(a.setupGate)
|
||||
|
||||
// Public auth pages.
|
||||
@@ -106,6 +114,8 @@ func (a *App) Routes() http.Handler {
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-resend", a.PostTestResend)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser)
|
||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
||||
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
// defaultPollIntervalMinutes is the poll cadence pre-selected on the add-item
|
||||
// form: 12 hours, chosen to keep Apify spend low by default.
|
||||
const defaultPollIntervalMinutes = 720
|
||||
|
||||
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
cat := r.URL.Query().Get("category")
|
||||
all, err := a.Store.ListItems(r.Context())
|
||||
@@ -56,8 +60,11 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||
IsEdit: false,
|
||||
Categories: cats,
|
||||
Item: models.Item{
|
||||
NtfyPriority: "default",
|
||||
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes,
|
||||
NtfyPriority: "default",
|
||||
// Default new items to a 12-hour refresh. Apify runs cost credits
|
||||
// and most watched items do not move hour-to-hour, so a slow
|
||||
// default keeps spend down; the operator can dial it tighter.
|
||||
PollIntervalMinutes: defaultPollIntervalMinutes,
|
||||
Marketplaces: []string{"ebay.com"},
|
||||
ListingType: "all",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/ebay"
|
||||
"veola/internal/email"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/templates"
|
||||
@@ -24,6 +25,10 @@ var settingsKeys = []string{
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"match_confidence_threshold",
|
||||
"apify_cost_per_call",
|
||||
"monthly_budget_usd",
|
||||
"resend_api_key",
|
||||
"resend_from",
|
||||
}
|
||||
|
||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
|
||||
"ebay_client_id": true,
|
||||
"ebay_client_secret": true,
|
||||
"ntfy_token": true,
|
||||
"resend_api_key": true,
|
||||
}
|
||||
|
||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
||||
@@ -45,6 +51,7 @@ func (a *App) credentialStatus(values map[string]string) map[string]string {
|
||||
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||
"ntfy_token": "",
|
||||
"resend_api_key": a.Cfg.Resend.APIKey,
|
||||
}
|
||||
status := make(map[string]string, len(secretSettingsKeys))
|
||||
for k := range secretSettingsKeys {
|
||||
@@ -71,6 +78,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
users, _ := a.Store.ListUsers(r.Context())
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
ApifyToday: apifyToday,
|
||||
ApifyMonth: apifyMonth,
|
||||
ApifyCostPerCall: costPerCall,
|
||||
MonthlyBudget: monthlyBudget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PostEmailPrefs lets the signed-in user set their own notification email and
|
||||
// opt into deal-alert / weekly-digest email. Available to every user, not just
|
||||
// admins (each user owns their own delivery prefs).
|
||||
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.PostFormValue("email"))
|
||||
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
|
||||
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
|
||||
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if email == "" && (dealAlerts || weeklyDigest) {
|
||||
d.EmailError = "Set an email address to receive email notifications."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Reflect the saved values immediately (CurrentUser in d predates the write).
|
||||
d, err = a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if d.Page.CurrentUser != nil {
|
||||
d.Page.CurrentUser.Email = email
|
||||
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
|
||||
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
|
||||
}
|
||||
d.EmailMsg = "Email preferences saved."
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
// PostTestResend sends a test email to the admin's own address to verify the
|
||||
// Resend configuration.
|
||||
func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil || cur.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
|
||||
if apiKey == "" {
|
||||
apiKey = a.Cfg.Resend.APIKey
|
||||
}
|
||||
from := strings.TrimSpace(d.Values["resend_from"])
|
||||
if from == "" {
|
||||
from = a.Cfg.Resend.From
|
||||
}
|
||||
to := ""
|
||||
if cur != nil {
|
||||
to = cur.Email
|
||||
}
|
||||
if to == "" {
|
||||
d.TestResendOK = "Set your notification email below first, then test."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := email.New(apiKey, from)
|
||||
if err := client.Send(r.Context(), email.Message{
|
||||
To: to,
|
||||
Subject: "Veola test email",
|
||||
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
|
||||
Text: "Test email from Veola settings. Resend is configured correctly.",
|
||||
}); err != nil {
|
||||
d.TestResendOK = "Resend test failed: " + err.Error()
|
||||
} else {
|
||||
d.TestResendOK = "Sent a test email to " + to + "."
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user