Auction end times, visual flair, and pre-launch cleanup
Auction handling: - Capture itemEndDate from eBay Browse API and ending_date from ZenMarket (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket parser (multiple layouts, JST when offset missing). - Per-row "Ends" countdown column + "Ending soon" banner on results pages, live-ticked by flair.js with urgent/critical tinting under 1h/5m. - Backfill ends_at for known auctions when their URL reappears in a poll (dedup hit no longer drops the new end time). - Hide ended auctions from result listings by default via ResultsQuery.ExcludeEnded; rows stay in the DB. Visual flair: - Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift. - htmx swap fade-in via transient .v-just-swapped class. - Count-up animation on dashboard stats. All animations gated behind prefers-reduced-motion. eBay condition + region filters (auctions-style scoping): - items.condition and items.region columns; threaded through item form, CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so cache invalidates when these change. - ebay.SearchParams gains conditionIds and itemLocationCountry filters. Run Now reload + countdown engine: - Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so the entire results view — best price, chart, badge, last polled — reflects the new poll, instead of swapping just one partial. Pre-launch hardening (P1 set): - auth.EqualizeLoginTiming on no-such-user branch. - (*App).serverError centralizes 500s; replaces err.Error() leaks across results/settings/items/users/dashboard handlers. - main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s alongside the existing ReadHeaderTimeout. - noListFS wrapper blocks static directory listings. - Credential fields in settings no longer render value=; blank submission preserves the saved value, with per-field "Saved in settings / Set in config.toml / Not set" status indicator. Misc: - -debug flag wires slog to LevelDebug; raw ZenMarket items logged for format diagnosis. - /healthz public endpoint for reverse-proxy probes. - deploy/veola.service systemd unit template (hardening flags, single ReadWritePaths=/var/lib/veola). - handlers_test.go covers /healthz, setup-gate redirect, auth gate, and /login render with httptest + in-memory sqlite. - best_price_currency on items; templates pick the right symbol per row. - .gitignore now excludes *.log / veola-debug.log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,11 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
u, err := a.Store.GetUserByUsername(r.Context(), username)
|
||||
if err != nil || u == nil {
|
||||
// Run a bcrypt comparison anyway so a missing username takes the
|
||||
// same time as a wrong password (no user-enumeration oracle).
|
||||
auth.EqualizeLoginTiming()
|
||||
}
|
||||
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||
render(w, r, templates.Login(templates.LoginData{
|
||||
Page: a.page(r, "Sign in", ""),
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.dashboardData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Dashboard(d))
|
||||
@@ -21,7 +21,7 @@ func (a *App) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.dashboardData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Render ONLY the inner body. The hx-swap="outerHTML" on DashboardBody's
|
||||
@@ -36,7 +36,7 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20})
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20, ExcludeEnded: true})
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -52,9 +53,19 @@ func (a *App) Routes() http.Handler {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders)
|
||||
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
// noListFS denies directory requests, so http.FileServer can't render
|
||||
// an index listing of static/ if an index.html is ever absent.
|
||||
fs := http.FileServer(noListFS{http.Dir("./static")})
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", fs))
|
||||
|
||||
// Health check for reverse-proxy/uptime probes. No session, no setup
|
||||
// gate, no auth — just a 200 to confirm the process is serving.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// All other routes pass through session loading + setup gate.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||
@@ -169,6 +180,34 @@ func (a *App) page(r *http.Request, title, active string) templates.Page {
|
||||
}
|
||||
}
|
||||
|
||||
// noListFS wraps an http.FileSystem and refuses to open directories, which
|
||||
// stops http.FileServer from emitting an auto-generated directory listing.
|
||||
type noListFS struct{ fs http.FileSystem }
|
||||
|
||||
func (n noListFS) Open(name string) (http.File, error) {
|
||||
f, err := n.fs.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
f.Close()
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// serverError logs the underlying error and returns a generic 500 to the
|
||||
// client, so internal details (DB errors, file paths) never reach the browser.
|
||||
func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("handler error", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := c.Render(r.Context(), w); err != nil {
|
||||
|
||||
115
internal/handlers/handlers_test.go
Normal file
115
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/config"
|
||||
"veola/internal/crypto"
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/internal/scheduler"
|
||||
)
|
||||
|
||||
// newTestApp builds an App backed by a fresh sqlite db in t.TempDir(). The
|
||||
// scheduler, apify, and ntfy clients are wired but unused by the routes we
|
||||
// hit here. The returned http.Handler is App.Routes().
|
||||
func newTestApp(t *testing.T) (*App, http.Handler) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
sqlDB, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
|
||||
key, err := crypto.DeriveKey([]byte("test-encryption-key-32-bytes-min-aaaaaa"))
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey: %v", err)
|
||||
}
|
||||
store := db.NewStore(sqlDB, key)
|
||||
|
||||
am, err := auth.NewManager(sqlDB, store, strings.Repeat("a", 32), false)
|
||||
if err != nil {
|
||||
t.Fatalf("auth.NewManager: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
ap := apify.New("")
|
||||
nt := ntfy.New("")
|
||||
sc := scheduler.New(cfg, store, ap, nt)
|
||||
|
||||
app := New(cfg, store, am, ap, nt, sc)
|
||||
return app, app.Routes()
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
_, h := newTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "ok" {
|
||||
t.Fatalf("body = %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGateRedirectsWhenNoUsers(t *testing.T) {
|
||||
_, h := newTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/setup" {
|
||||
t.Fatalf("Location = %q, want /setup", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRedirectsToLogin(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
hash, err := auth.HashPassword("a-long-enough-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
app, h := newTestApp(t)
|
||||
hash, _ := auth.HashPassword("a-long-enough-password")
|
||||
if _, err := app.Store.CreateUser(context.Background(), "admin", hash, models.RoleAdmin); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<form") {
|
||||
t.Fatalf("body missing <form>")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ func parseItemForm(r *http.Request) (models.Item, []string) {
|
||||
}
|
||||
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
|
||||
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
|
||||
it.Condition = strings.TrimSpace(r.PostFormValue("condition"))
|
||||
it.Region = strings.ToUpper(strings.TrimSpace(r.PostFormValue("region")))
|
||||
it.ActorActive = strings.TrimSpace(r.PostFormValue("actor_active"))
|
||||
it.ActorSold = strings.TrimSpace(r.PostFormValue("actor_sold"))
|
||||
it.ActorPriceCompare = strings.TrimSpace(r.PostFormValue("actor_price_compare"))
|
||||
@@ -253,6 +255,8 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
|
||||
Marketplace: previewMarket,
|
||||
ListingType: it.ListingType,
|
||||
ActorIDs: strings.Join(actorIDs, ","),
|
||||
Condition: it.Condition,
|
||||
Region: it.Region,
|
||||
MaxResults: 30,
|
||||
}
|
||||
if cached, src, ok := a.Preview.Get(key); ok {
|
||||
@@ -304,6 +308,8 @@ func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
|
||||
IncludeOutOfStock: it.IncludeOutOfStock,
|
||||
Marketplaces: it.Marketplaces,
|
||||
ListingType: it.ListingType,
|
||||
Condition: it.Condition,
|
||||
Region: it.Region,
|
||||
ActorActive: it.ActorActive,
|
||||
ActorSold: it.ActorSold,
|
||||
ActorPriceCompare: it.ActorPriceCompare,
|
||||
@@ -319,7 +325,7 @@ func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
it.ID = id
|
||||
@@ -361,7 +367,7 @@ func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
updated.ID = id
|
||||
updated.Active = existing.Active
|
||||
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(updated)
|
||||
@@ -377,7 +383,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
it.Active = !it.Active
|
||||
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(*it)
|
||||
@@ -387,7 +393,7 @@ func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) PostDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
if err := a.Store.DeleteItem(r.Context(), id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Scheduler.RemoveItem(id)
|
||||
@@ -410,31 +416,22 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
a.Scheduler.RunPoll(ctx, *it)
|
||||
|
||||
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch
|
||||
// so the rendered partial shows the post-poll state.
|
||||
fresh, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || fresh == nil {
|
||||
http.Error(w, "could not reload item after run", http.StatusInternalServerError)
|
||||
// A partial swap (single row or just the results table) leaves the rest
|
||||
// of the page — best-price card, price chart, "last polled" time, badge —
|
||||
// looking stale, so the run reads as a no-op. Tell htmx to do a full
|
||||
// reload so every derived view picks up the post-poll state.
|
||||
if r.Header.Get("HX-Request") != "" {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// The results page asks for a refreshed listing table; the items list
|
||||
// asks for a refreshed row. Both POST to this same endpoint.
|
||||
// Non-htmx fallback: redirect back to the originating page.
|
||||
target := "/items"
|
||||
if r.PostFormValue("from") == "results" {
|
||||
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if fresh.LastPollError != "" {
|
||||
d.RunError = "Run finished with errors: " + fresh.LastPollError
|
||||
} else {
|
||||
d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results))
|
||||
}
|
||||
render(w, r, templates.ItemResultsTable(d))
|
||||
return
|
||||
target = fmt.Sprintf("/items/%d/results", id)
|
||||
}
|
||||
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,8 +10,13 @@ import (
|
||||
// previewKey caches the *raw* apify result set (post-decode, post-merge,
|
||||
// pre-filter). Filters like min_price and exclude_keywords are applied after
|
||||
// the cache lookup so the operator can iterate on them without burning credits.
|
||||
//
|
||||
// Condition and Region are part of the key, not post-filters: they are
|
||||
// server-side eBay Browse API filters that change the result set the API
|
||||
// returns, so a different condition/region must miss the cache.
|
||||
type previewKey struct {
|
||||
Queries, URL, Marketplace, ListingType, ActorIDs string
|
||||
Condition, Region string
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
d, err := a.buildItemResultsData(r, it, page, r.URL.Query().Get("order"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.ItemResults(d))
|
||||
@@ -41,7 +41,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
||||
page = 1
|
||||
}
|
||||
|
||||
total, err := a.Store.CountResults(r.Context(), it.ID)
|
||||
total, err := a.Store.CountResults(r.Context(), it.ID, true)
|
||||
if err != nil {
|
||||
return templates.ItemResultsData{}, err
|
||||
}
|
||||
@@ -54,10 +54,11 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: it.ID,
|
||||
Limit: resultsPerPage,
|
||||
Offset: (page - 1) * resultsPerPage,
|
||||
Order: order,
|
||||
ItemID: it.ID,
|
||||
Limit: resultsPerPage,
|
||||
Offset: (page - 1) * resultsPerPage,
|
||||
Order: order,
|
||||
ExcludeEnded: true,
|
||||
})
|
||||
if err != nil {
|
||||
return templates.ItemResultsData{}, err
|
||||
@@ -68,6 +69,10 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
||||
return templates.ItemResultsData{}, err
|
||||
}
|
||||
|
||||
// 24h surface for the "ending soon" strip — beyond that, a static
|
||||
// "ends in 4 days" in the per-row cell carries enough signal on its own.
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), it.ID, 24*time.Hour)
|
||||
|
||||
return templates.ItemResultsData{
|
||||
Page: a.page(r, it.Name, "items"),
|
||||
Item: *it,
|
||||
@@ -78,6 +83,7 @@ func (a *App) buildItemResultsData(r *http.Request, it *models.Item, page int, o
|
||||
TotalPages: totalPages,
|
||||
Order: order,
|
||||
HistoryChartJSON: buildChartJSON(history),
|
||||
EndingSoon: endingSoon,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -101,7 +107,7 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
items, err := a.Store.ListItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
names := make(map[int64]string, len(items))
|
||||
@@ -110,11 +116,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: itemID,
|
||||
Limit: 200,
|
||||
ItemID: itemID,
|
||||
Limit: 200,
|
||||
ExcludeEnded: true,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,12 +145,15 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
endingSoon, _ := a.Store.NextEndingResult(r.Context(), itemID, 24*time.Hour)
|
||||
|
||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||
Page: a.page(r, "Results", "results"),
|
||||
Items: items,
|
||||
Results: rows,
|
||||
ItemID: itemID,
|
||||
From: from,
|
||||
To: to,
|
||||
Page: a.page(r, "Results", "results"),
|
||||
Items: items,
|
||||
Results: rows,
|
||||
ItemID: itemID,
|
||||
From: from,
|
||||
To: to,
|
||||
EndingSoon: endingSoon,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,40 @@ var settingsKeys = []string{
|
||||
"match_confidence_threshold",
|
||||
}
|
||||
|
||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||
// back into the form, so a blank submission means "leave unchanged" rather
|
||||
// than "clear" — see PostSettings.
|
||||
var secretSettingsKeys = map[string]bool{
|
||||
"apify_api_key": true,
|
||||
"ebay_client_id": true,
|
||||
"ebay_client_secret": true,
|
||||
"ntfy_token": true,
|
||||
}
|
||||
|
||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
||||
// settings table, inherited from config.toml, or absent — without exposing
|
||||
// the secret itself.
|
||||
func (a *App) credentialStatus(values map[string]string) map[string]string {
|
||||
configVals := map[string]string{
|
||||
"apify_api_key": a.Cfg.Apify.APIKey,
|
||||
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||
"ntfy_token": "",
|
||||
}
|
||||
status := make(map[string]string, len(secretSettingsKeys))
|
||||
for k := range secretSettingsKeys {
|
||||
switch {
|
||||
case strings.TrimSpace(values[k]) != "":
|
||||
status[k] = "Saved in settings"
|
||||
case strings.TrimSpace(configVals[k]) != "":
|
||||
status[k] = "Set in config.toml"
|
||||
default:
|
||||
status[k] = "Not set"
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
values, err := a.Store.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
@@ -38,19 +72,20 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
CredentialStatus: a.credentialStatus(values),
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
@@ -68,8 +103,14 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, k := range settingsKeys {
|
||||
v := strings.TrimSpace(r.PostFormValue(k))
|
||||
// Secret fields are never rendered back into the form, so a blank
|
||||
// submission is the normal state and means "leave unchanged" — not
|
||||
// "clear". (To clear a stored credential, edit the settings table.)
|
||||
if v == "" && secretSettingsKeys[k] {
|
||||
continue
|
||||
}
|
||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -92,7 +133,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,7 +156,7 @@ func (a *App) PostPasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
d.PasswordMsg = "Password updated"
|
||||
@@ -130,7 +171,7 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||
@@ -164,7 +205,7 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(d.Values["apify_api_key"])
|
||||
@@ -210,7 +251,7 @@ func (a *App) PostTestEbay(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Settings-table values win over config.toml. Both paths are trimmed:
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func (a *App) renderSettingsWithUserMsg(w http.ResponseWriter, r *http.Request, msg, errMsg string) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
d.UserMsg = msg
|
||||
|
||||
Reference in New Issue
Block a user