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>
332 lines
9.9 KiB
Go
332 lines
9.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"veola/internal/apify"
|
|
"veola/internal/auth"
|
|
"veola/internal/ebay"
|
|
"veola/internal/models"
|
|
"veola/internal/ntfy"
|
|
"veola/templates"
|
|
)
|
|
|
|
var settingsKeys = []string{
|
|
"apify_api_key",
|
|
"ebay_client_id",
|
|
"ebay_client_secret",
|
|
"ebay_daily_call_limit",
|
|
"ntfy_base_url",
|
|
"ntfy_default_topic",
|
|
"ntfy_token",
|
|
"global_poll_interval_minutes",
|
|
"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 {
|
|
return templates.SettingsData{}, err
|
|
}
|
|
if values == nil {
|
|
values = map[string]string{}
|
|
}
|
|
users, _ := a.Store.ListUsers(r.Context())
|
|
cur := auth.CurrentUserFromRequest(r)
|
|
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
|
return templates.SettingsData{
|
|
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 {
|
|
a.serverError(w, r, err)
|
|
return
|
|
}
|
|
render(w, r, templates.Settings(d))
|
|
}
|
|
|
|
func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
|
cur := auth.CurrentUserFromRequest(r)
|
|
if cur == nil || cur.Role != models.RoleAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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 {
|
|
a.serverError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *App) PostPasswordChange(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
|
|
}
|
|
current := r.PostFormValue("current_password")
|
|
next := r.PostFormValue("new_password")
|
|
confirm := r.PostFormValue("new_password_confirm")
|
|
|
|
d, err := a.settingsData(r)
|
|
if err != nil {
|
|
a.serverError(w, r, err)
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case !auth.CheckPassword(cur.PasswordHash, current):
|
|
d.PasswordError = "Current password is incorrect"
|
|
case len(next) < auth.MinPasswordLen:
|
|
d.PasswordError = fmt.Sprintf("New password must be at least %d characters", auth.MinPasswordLen)
|
|
case next != confirm:
|
|
d.PasswordError = "New passwords do not match"
|
|
}
|
|
if d.PasswordError != "" {
|
|
render(w, r, templates.Settings(d))
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(next)
|
|
if err != nil {
|
|
http.Error(w, "hash error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := a.Store.UpdateUserPassword(r.Context(), cur.ID, hash); err != nil {
|
|
a.serverError(w, r, err)
|
|
return
|
|
}
|
|
d.PasswordMsg = "Password updated"
|
|
render(w, r, templates.Settings(d))
|
|
}
|
|
|
|
func (a *App) PostTestNtfy(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
|
|
}
|
|
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
|
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
|
token := strings.TrimSpace(d.Values["ntfy_token"])
|
|
if baseURL == "" || topic == "" {
|
|
d.TestNtfyOK = "Set ntfy base URL and default topic first."
|
|
render(w, r, templates.Settings(d))
|
|
return
|
|
}
|
|
client := ntfy.NewWithToken(baseURL, token)
|
|
if err := client.Send(r.Context(), ntfy.Notification{
|
|
Topic: topic,
|
|
Title: "Veola test",
|
|
Message: "Test notification from Veola settings.",
|
|
Priority: "default",
|
|
Tags: []string{"white_check_mark"},
|
|
}); err != nil {
|
|
d.TestNtfyOK = "Ntfy test failed: " + err.Error()
|
|
} else {
|
|
d.TestNtfyOK = fmt.Sprintf("Sent test notification to %s/%s", baseURL, topic)
|
|
}
|
|
render(w, r, templates.Settings(d))
|
|
}
|
|
|
|
func (a *App) PostTestApify(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["apify_api_key"])
|
|
actorID := a.Cfg.Apify.Actors.ActiveListings
|
|
if apiKey == "" {
|
|
apiKey = a.Cfg.Apify.APIKey
|
|
}
|
|
if apiKey == "" || actorID == "" {
|
|
d.TestApifyOK = "Apify API key or active_listings actor is not configured."
|
|
render(w, r, templates.Settings(d))
|
|
return
|
|
}
|
|
client := apify.New(apiKey)
|
|
var proxy *apify.ProxyConfiguration
|
|
p := a.Cfg.Apify.Proxy
|
|
if p.UseApifyProxy {
|
|
proxy = &apify.ProxyConfiguration{
|
|
UseApifyProxy: true,
|
|
ApifyProxyGroups: p.Groups,
|
|
ApifyProxyCountry: p.Country,
|
|
}
|
|
}
|
|
raw, err := client.Run(r.Context(), actorID, apify.ActiveListingInput{
|
|
SearchQueries: []string{"test"},
|
|
MaxProductsPerSearch: 1,
|
|
MaxSearchPages: 1,
|
|
ListingType: "all",
|
|
ProxyConfiguration: proxy,
|
|
})
|
|
if err != nil {
|
|
d.TestApifyOK = "Apify test failed: " + err.Error()
|
|
} else {
|
|
d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw))
|
|
}
|
|
render(w, r, templates.Settings(d))
|
|
}
|
|
|
|
func (a *App) PostTestEbay(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
|
|
}
|
|
// Settings-table values win over config.toml. Both paths are trimmed:
|
|
// a stray newline in the TOML would otherwise reach eBay verbatim.
|
|
clientID := strings.TrimSpace(d.Values["ebay_client_id"])
|
|
if clientID == "" {
|
|
clientID = strings.TrimSpace(a.Cfg.Ebay.ClientID)
|
|
}
|
|
clientSecret := strings.TrimSpace(d.Values["ebay_client_secret"])
|
|
if clientSecret == "" {
|
|
clientSecret = strings.TrimSpace(a.Cfg.Ebay.ClientSecret)
|
|
}
|
|
if clientID == "" || clientSecret == "" {
|
|
d.TestEbayOK = "Set the eBay App ID and Cert ID first."
|
|
render(w, r, templates.Settings(d))
|
|
return
|
|
}
|
|
if d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit {
|
|
d.TestEbayOK = fmt.Sprintf("Daily eBay API call limit reached (%d/%d). Test skipped.", d.EbayUsedToday, d.EbayDailyLimit)
|
|
render(w, r, templates.Settings(d))
|
|
return
|
|
}
|
|
|
|
env := "production"
|
|
if strings.EqualFold(strings.TrimSpace(a.Cfg.Ebay.Environment), "sandbox") {
|
|
env = "sandbox"
|
|
}
|
|
// Echo back exactly what was sent (App ID masked, Cert ID length only) so
|
|
// a failure points at the inputs, not just "it failed".
|
|
inputs := fmt.Sprintf("%s, App ID %s, Cert ID %d chars", env, maskID(clientID), len(clientSecret))
|
|
|
|
client := ebay.New(clientID, clientSecret, a.Cfg.Ebay.Environment)
|
|
listings, err := client.Search(r.Context(), ebay.SearchParams{
|
|
MarketplaceID: "EBAY_US",
|
|
Query: "test",
|
|
Limit: 1,
|
|
})
|
|
// A real call was made; count it against the daily allowance.
|
|
if n, incErr := a.Store.IncrementEbayUsage(r.Context()); incErr == nil {
|
|
d.EbayUsedToday = n
|
|
}
|
|
if err != nil {
|
|
msg := fmt.Sprintf("eBay test failed (%s): %s", inputs, err.Error())
|
|
if ks := ebayKeysetEnv(clientID); ks != "" && ks != env {
|
|
msg += fmt.Sprintf(" — the App ID looks like a %s keyset, but environment is %q. Set environment = %q in the [ebay] config block (or use your %s keyset).", ks, env, ks, env)
|
|
}
|
|
d.TestEbayOK = msg
|
|
} else {
|
|
d.TestEbayOK = fmt.Sprintf("eBay Browse API reachable (%s). Returned %d item(s).", inputs, len(listings))
|
|
}
|
|
render(w, r, templates.Settings(d))
|
|
}
|
|
|
|
// maskID returns a fingerprint of a credential for display: enough of the head
|
|
// to recognize it, the rest elided. Used only for the App ID (the non-secret
|
|
// half of the OAuth pair) — never for the Cert ID.
|
|
func maskID(s string) string {
|
|
if len(s) <= 12 {
|
|
return strings.Repeat("•", len(s))
|
|
}
|
|
return s[:12] + "…(" + strconv.Itoa(len(s)) + " chars)"
|
|
}
|
|
|
|
// ebayKeysetEnv guesses which environment an eBay App ID belongs to from the
|
|
// SBX/PRD marker eBay embeds in it (e.g. "Name-app-PRD-1a2b..."). Returns ""
|
|
// when no marker is present.
|
|
func ebayKeysetEnv(clientID string) string {
|
|
up := strings.ToUpper(clientID)
|
|
switch {
|
|
case strings.Contains(up, "-SBX-"):
|
|
return "sandbox"
|
|
case strings.Contains(up, "-PRD-"):
|
|
return "production"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|