Initial commit
This commit is contained in:
113
internal/handlers/auth.go
Normal file
113
internal/handlers/auth.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) GetLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if auth.CurrentUserFromRequest(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Login(templates.LoginData{
|
||||
Page: a.page(r, "Sign in", ""),
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
u, err := a.Store.GetUserByUsername(r.Context(), username)
|
||||
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||
render(w, r, templates.Login(templates.LoginData{
|
||||
Page: a.page(r, "Sign in", ""),
|
||||
Error: "Invalid username or password",
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if err := a.Auth.LogIn(r.Context(), u.ID); err != nil {
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.Auth.LogOut(r.Context())
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) GetSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) PostSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
confirm := r.PostFormValue("password_confirm")
|
||||
errMsg := ""
|
||||
switch {
|
||||
case username == "":
|
||||
errMsg = "Username is required"
|
||||
case len(password) < auth.MinPasswordLen:
|
||||
errMsg = "Password must be at least 12 characters"
|
||||
case password != confirm:
|
||||
errMsg = "Passwords do not match"
|
||||
}
|
||||
if errMsg != "" {
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
Error: errMsg,
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "hash error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.CreateUser(r.Context(), username, hash, models.RoleAdmin); err != nil {
|
||||
render(w, r, templates.Setup(templates.SetupData{
|
||||
Page: a.page(r, "Setup", ""),
|
||||
Error: "Could not create user: " + err.Error(),
|
||||
Username: username,
|
||||
}))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
106
internal/handlers/dashboard.go
Normal file
106
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
render(w, r, templates.Dashboard(d))
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
// Render only the inner block by reusing the full body component; the
|
||||
// outer hx-swap="outerHTML" replaces the same wrapper. The full Dashboard
|
||||
// template is overkill but keeps a single source of truth.
|
||||
render(w, r, templates.Dashboard(d))
|
||||
}
|
||||
|
||||
func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) {
|
||||
stats, err := a.Store.GetDashboardStats(r.Context())
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{Limit: 20})
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
itemNames := map[int64]string{}
|
||||
all, _ := a.Store.ListItems(r.Context())
|
||||
for _, it := range all {
|
||||
itemNames[it.ID] = it.Name
|
||||
}
|
||||
rrs := make([]templates.ResultRow, 0, len(results))
|
||||
for _, r := range results {
|
||||
rrs = append(rrs, templates.ResultRow{
|
||||
ItemID: r.ItemID,
|
||||
ItemName: itemNames[r.ItemID],
|
||||
Title: r.Title,
|
||||
Price: r.Price,
|
||||
Currency: r.Currency,
|
||||
Source: r.Source,
|
||||
URL: r.URL,
|
||||
FoundAt: r.FoundAt,
|
||||
Alerted: r.Alerted,
|
||||
})
|
||||
}
|
||||
alerts, err := alertsRecent(a, r, itemNames)
|
||||
if err != nil {
|
||||
return templates.DashboardData{}, err
|
||||
}
|
||||
return templates.DashboardData{
|
||||
Page: a.page(r, "Dashboard", "dashboard"),
|
||||
Stats: stats,
|
||||
RecentResults: rrs,
|
||||
RecentAlerts: alerts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func alertsRecent(a *App, r *http.Request, itemNames map[int64]string) ([]templates.AlertRow, error) {
|
||||
rows, err := a.Store.DB.QueryContext(r.Context(), `
|
||||
SELECT item_id, price, currency, found_at FROM results
|
||||
WHERE alerted = 1 ORDER BY found_at DESC LIMIT 5
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []templates.AlertRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
itemID int64
|
||||
price sql.NullFloat64
|
||||
currency string
|
||||
foundAt time.Time
|
||||
)
|
||||
if err := rows.Scan(&itemID, &price, ¤cy, &foundAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p *float64
|
||||
if price.Valid {
|
||||
v := price.Float64
|
||||
p = &v
|
||||
}
|
||||
out = append(out, templates.AlertRow{
|
||||
ItemName: itemNames[itemID],
|
||||
Price: p,
|
||||
Currency: currency,
|
||||
FoundAt: foundAt,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
146
internal/handlers/handlers.go
Normal file
146
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package handlers wires HTTP routes for Veola. Each file in the package owns
|
||||
// a related cluster of routes; this file holds the shared App container and
|
||||
// helper functions used across all handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/config"
|
||||
"veola/internal/db"
|
||||
"veola/internal/ntfy"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Cfg *config.Config
|
||||
Store *db.Store
|
||||
Auth *auth.Manager
|
||||
Apify *apify.Client
|
||||
Ntfy *ntfy.Client
|
||||
Scheduler *scheduler.Scheduler
|
||||
Preview *PreviewCache
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client, nt *ntfy.Client, sc *scheduler.Scheduler) *App {
|
||||
return &App{
|
||||
Cfg: cfg, Store: store, Auth: am,
|
||||
Apify: ap, Ntfy: nt, Scheduler: sc,
|
||||
Preview: NewPreviewCache(10 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router with everything wired up.
|
||||
func (a *App) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", fs))
|
||||
|
||||
// All other routes pass through session loading + setup gate.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||
r.Use(a.Auth.LoadUser)
|
||||
r.Use(a.setupGate)
|
||||
|
||||
// Public auth pages.
|
||||
r.Get("/login", a.GetLogin)
|
||||
r.With(a.Auth.CSRFProtect).Post("/login", a.PostLogin)
|
||||
r.Get("/setup", a.GetSetup)
|
||||
r.With(a.Auth.CSRFProtect).Post("/setup", a.PostSetup)
|
||||
|
||||
// Authenticated section.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.RequireAuth)
|
||||
r.With(a.Auth.CSRFProtect).Post("/logout", a.PostLogout)
|
||||
|
||||
r.Get("/", a.GetDashboard)
|
||||
r.Get("/dashboard/refresh", a.GetDashboardRefresh)
|
||||
|
||||
r.Get("/items", a.GetItems)
|
||||
r.Get("/items/new", a.GetNewItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/preview", a.PostPreview)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items", a.PostCreateItem)
|
||||
r.Get("/items/{id}/edit", a.GetEditItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}", a.PostUpdateItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/toggle", a.PostToggleItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/delete", a.PostDeleteItem)
|
||||
r.With(a.Auth.CSRFProtect).Post("/items/{id}/run", a.PostRunItem)
|
||||
r.Get("/items/{id}/error", a.GetItemError)
|
||||
r.Get("/items/{id}/results", a.GetItemResults)
|
||||
|
||||
r.Get("/results", a.GetGlobalResults)
|
||||
|
||||
r.Get("/settings", a.GetSettings)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings", a.PostSettings)
|
||||
r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange)
|
||||
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, 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)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// setupGate redirects every request to /setup if no users exist.
|
||||
func (a *App) setupGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/setup" || isStaticPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
n, err := a.Store.UserCount(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Once at least one user exists, /setup is a 404.
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isStaticPath(p string) bool {
|
||||
return len(p) >= 8 && p[:8] == "/static/"
|
||||
}
|
||||
|
||||
func (a *App) page(r *http.Request, title, active string) templates.Page {
|
||||
return templates.Page{
|
||||
Title: title,
|
||||
Active: active,
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
CurrentUser: auth.CurrentUserFromRequest(r),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
slog.Error("render failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func intParam(r *http.Request, key string) int64 {
|
||||
return parseInt64(chi.URLParam(r, key))
|
||||
}
|
||||
|
||||
func ctxBg() context.Context { return context.Background() }
|
||||
453
internal/handlers/items.go
Normal file
453
internal/handlers/items.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/models"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||
cat := r.URL.Query().Get("category")
|
||||
all, err := a.Store.ListItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var items []models.Item
|
||||
for _, it := range all {
|
||||
if cat == "" || it.Category == cat {
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
render(w, r, templates.Items(templates.ItemsData{
|
||||
Page: a.page(r, "Items", "items"),
|
||||
Items: items,
|
||||
Categories: cats,
|
||||
SelectedCategory: cat,
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Add Item", "items"),
|
||||
IsEdit: false,
|
||||
Categories: cats,
|
||||
Item: models.Item{
|
||||
NtfyPriority: "default",
|
||||
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes,
|
||||
Marketplaces: []string{"ebay.com"},
|
||||
ListingType: "all",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) GetEditItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+it.Name, "items"),
|
||||
IsEdit: true,
|
||||
Item: *it,
|
||||
Categories: cats,
|
||||
}))
|
||||
}
|
||||
|
||||
// parseItemForm pulls form fields into a models.Item plus a list of validation
|
||||
// errors. Used by preview, create, and update.
|
||||
func parseItemForm(r *http.Request) (models.Item, []string) {
|
||||
var it models.Item
|
||||
var errs []string
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return it, []string{"could not parse form"}
|
||||
}
|
||||
it.Name = strings.TrimSpace(r.PostFormValue("name"))
|
||||
it.SearchQuery = strings.Join(models.SplitList(r.PostFormValue("search_query"), 10), "\n")
|
||||
it.ExcludeKeywords = strings.Join(models.SplitList(r.PostFormValue("exclude_keywords"), 20), "\n")
|
||||
it.URL = strings.TrimSpace(r.PostFormValue("url"))
|
||||
if newCat := strings.TrimSpace(r.PostFormValue("category_new")); newCat != "" {
|
||||
it.Category = newCat
|
||||
} else {
|
||||
it.Category = strings.TrimSpace(r.PostFormValue("category"))
|
||||
}
|
||||
it.NtfyTopic = strings.TrimSpace(r.PostFormValue("ntfy_topic"))
|
||||
it.NtfyPriority = strings.TrimSpace(r.PostFormValue("ntfy_priority"))
|
||||
if it.NtfyPriority == "" {
|
||||
it.NtfyPriority = "default"
|
||||
}
|
||||
it.Marketplaces = collectMarketplaces(r.PostForm["marketplace"], r.PostFormValue("marketplace_custom"))
|
||||
it.ListingType = strings.TrimSpace(r.PostFormValue("listing_type"))
|
||||
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"))
|
||||
it.IncludeOutOfStock = r.PostFormValue("include_out_of_stock") == "1"
|
||||
it.UsePriceComparison = r.PostFormValue("use_price_comparison") == "1"
|
||||
it.Active = true
|
||||
|
||||
if tp := strings.TrimSpace(r.PostFormValue("target_price")); tp != "" {
|
||||
if v, err := strconv.ParseFloat(tp, 64); err == nil && v >= 0 {
|
||||
it.TargetPrice = &v
|
||||
}
|
||||
}
|
||||
if mp := strings.TrimSpace(r.PostFormValue("min_price")); mp != "" {
|
||||
if v, err := strconv.ParseFloat(mp, 64); err == nil && v >= 0 {
|
||||
it.MinPrice = &v
|
||||
}
|
||||
}
|
||||
if pi := strings.TrimSpace(r.PostFormValue("poll_interval_minutes")); pi != "" {
|
||||
if v, err := strconv.Atoi(pi); err == nil && v > 0 {
|
||||
it.PollIntervalMinutes = v
|
||||
}
|
||||
}
|
||||
if it.PollIntervalMinutes == 0 {
|
||||
it.PollIntervalMinutes = 60
|
||||
}
|
||||
|
||||
if it.Name == "" {
|
||||
errs = append(errs, "name is required")
|
||||
}
|
||||
if it.SearchQuery == "" && it.URL == "" {
|
||||
errs = append(errs, "either search query or product URL is required")
|
||||
}
|
||||
if it.NtfyTopic == "" {
|
||||
// Default to a slug of the name.
|
||||
it.NtfyTopic = slugify(it.Name)
|
||||
}
|
||||
return it, errs
|
||||
}
|
||||
|
||||
// collectMarketplaces dedupes the checkbox values + custom CSV input into an
|
||||
// ordered slice. Checkbox order first, then custom entries in the order the
|
||||
// user typed them.
|
||||
func collectMarketplaces(checked []string, custom string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || seen[v] {
|
||||
return
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
for _, v := range checked {
|
||||
add(v)
|
||||
}
|
||||
for _, v := range strings.Split(custom, ",") {
|
||||
add(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ', r == '_', r == '-':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return "veola-item"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) PostPreview(w http.ResponseWriter, r *http.Request) {
|
||||
it, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Error: strings.Join(errs, "; "),
|
||||
}))
|
||||
return
|
||||
}
|
||||
results, source, cached, err := a.runPreview(r.Context(), it)
|
||||
if err != nil {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Error: err.Error(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
results = scheduler.FilterResults(results, a.Cfg.Scheduler.MatchConfidenceThreshold, it.IncludeOutOfStock)
|
||||
results = scheduler.ApplyItemFilters(results, it.MinPrice, it.ExcludeKeywordsList())
|
||||
if len(results) == 0 {
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Empty: true,
|
||||
}))
|
||||
return
|
||||
}
|
||||
bestIdx := scheduler.PickBest(results)
|
||||
minP, maxP := results[0].Price, results[0].Price
|
||||
stores := map[string]struct{}{}
|
||||
cur := results[0].Currency
|
||||
for _, r := range results {
|
||||
if r.Price < minP {
|
||||
minP = r.Price
|
||||
}
|
||||
if r.Price > maxP {
|
||||
maxP = r.Price
|
||||
}
|
||||
stores[r.Store] = struct{}{}
|
||||
}
|
||||
render(w, r, templates.ItemPreview(templates.PreviewData{
|
||||
CSRFToken: a.Auth.CSRFToken(r.Context()),
|
||||
Form: formValuesFromItem(it, r),
|
||||
Results: results,
|
||||
BestIndex: bestIdx,
|
||||
MinPrice: minP,
|
||||
MaxPrice: maxP,
|
||||
StoreCount: len(stores),
|
||||
Cached: cached,
|
||||
Currency: cur,
|
||||
}))
|
||||
_ = source
|
||||
}
|
||||
|
||||
func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedResult, string, bool, error) {
|
||||
plans := a.Scheduler.BuildPreviewInputs(it)
|
||||
if len(plans) == 0 {
|
||||
return nil, "", false, fmt.Errorf("no actor configured for this item")
|
||||
}
|
||||
previewMarket := ""
|
||||
if len(it.Marketplaces) > 0 {
|
||||
previewMarket = it.Marketplaces[0]
|
||||
}
|
||||
queries := it.SearchQueries()
|
||||
sortedQ := make([]string, len(queries))
|
||||
copy(sortedQ, queries)
|
||||
sort.Strings(sortedQ)
|
||||
actorIDs := make([]string, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
actorIDs = append(actorIDs, p.ActorID())
|
||||
}
|
||||
sort.Strings(actorIDs)
|
||||
key := previewKey{
|
||||
Queries: strings.Join(sortedQ, "\n"),
|
||||
URL: it.URL,
|
||||
Marketplace: previewMarket,
|
||||
ListingType: it.ListingType,
|
||||
ActorIDs: strings.Join(actorIDs, ","),
|
||||
MaxResults: 30,
|
||||
}
|
||||
if cached, src, ok := a.Preview.Get(key); ok {
|
||||
return cached, src, true, nil
|
||||
}
|
||||
var merged []apify.UnifiedResult
|
||||
primarySource := ""
|
||||
for _, p := range plans {
|
||||
actorID := p.ActorID()
|
||||
if actorID == "" {
|
||||
continue
|
||||
}
|
||||
raw, err := a.Apify.Run(ctx, actorID, p.Input())
|
||||
if err != nil {
|
||||
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
|
||||
continue
|
||||
}
|
||||
decoded, _ := apify.Decode(raw, p.Source())
|
||||
for i := range decoded {
|
||||
decoded[i].MatchedQuery = p.Query()
|
||||
}
|
||||
usable := 0
|
||||
for _, r := range decoded {
|
||||
if r.URL != "" && r.Price > 0 {
|
||||
usable++
|
||||
}
|
||||
}
|
||||
slog.Info("preview decoded",
|
||||
"marketplace", previewMarket,
|
||||
"actor", actorID,
|
||||
"query", p.Query(),
|
||||
"raw", len(raw),
|
||||
"decoded", len(decoded),
|
||||
"usable", usable,
|
||||
)
|
||||
if usable == 0 && len(raw) > 0 {
|
||||
var sample map[string]any
|
||||
if err := json.Unmarshal(raw[0], &sample); err == nil {
|
||||
ks := make([]string, 0, len(sample))
|
||||
for k := range sample {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
slog.Warn("preview decoded zero usable rows; raw item keys",
|
||||
"actor", actorID,
|
||||
"keys", ks,
|
||||
)
|
||||
}
|
||||
}
|
||||
merged = append(merged, decoded...)
|
||||
if primarySource == "" {
|
||||
primarySource = p.Source()
|
||||
}
|
||||
}
|
||||
merged = scheduler.DedupByURL(merged)
|
||||
a.Preview.Put(key, merged, primarySource)
|
||||
return merged, primarySource, false, nil
|
||||
}
|
||||
|
||||
func formValuesFromItem(it models.Item, r *http.Request) templates.FormValues {
|
||||
tp := ""
|
||||
if it.TargetPrice != nil {
|
||||
tp = fmt.Sprintf("%.2f", *it.TargetPrice)
|
||||
}
|
||||
mp := ""
|
||||
if it.MinPrice != nil {
|
||||
mp = fmt.Sprintf("%.2f", *it.MinPrice)
|
||||
}
|
||||
return templates.FormValues{
|
||||
Name: it.Name,
|
||||
SearchQuery: it.SearchQuery,
|
||||
URL: it.URL,
|
||||
Category: it.Category,
|
||||
TargetPrice: tp,
|
||||
MinPrice: mp,
|
||||
ExcludeKeywords: it.ExcludeKeywords,
|
||||
NtfyTopic: it.NtfyTopic,
|
||||
NtfyPriority: it.NtfyPriority,
|
||||
PollIntervalMinutes: fmt.Sprintf("%d", it.PollIntervalMinutes),
|
||||
IncludeOutOfStock: it.IncludeOutOfStock,
|
||||
Marketplaces: it.Marketplaces,
|
||||
ListingType: it.ListingType,
|
||||
ActorActive: it.ActorActive,
|
||||
ActorSold: it.ActorSold,
|
||||
ActorPriceCompare: it.ActorPriceCompare,
|
||||
UsePriceComparison: it.UsePriceComparison,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PostCreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
it, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
http.Error(w, strings.Join(errs, "; "), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := a.Store.CreateItem(r.Context(), &it)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save item: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
it.ID = id
|
||||
a.Scheduler.SyncItem(it)
|
||||
|
||||
go func() {
|
||||
bg := context.Background()
|
||||
fresh, err := a.Store.GetItem(bg, id)
|
||||
if err != nil || fresh == nil {
|
||||
return
|
||||
}
|
||||
a.Scheduler.SeedSoldHistory(bg, *fresh)
|
||||
a.Scheduler.RunPoll(bg, *fresh)
|
||||
}()
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/items/%d/results", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostUpdateItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
existing, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || existing == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
updated, errs := parseItemForm(r)
|
||||
if len(errs) > 0 {
|
||||
cats, _ := a.Store.ListCategories(r.Context())
|
||||
updated.ID = id
|
||||
render(w, r, templates.ItemForm(templates.ItemFormData{
|
||||
Page: a.page(r, "Edit "+updated.Name, "items"),
|
||||
IsEdit: true,
|
||||
Item: updated,
|
||||
Errors: errs,
|
||||
Categories: cats,
|
||||
}))
|
||||
return
|
||||
}
|
||||
updated.ID = id
|
||||
updated.Active = existing.Active
|
||||
if err := a.Store.UpdateItem(r.Context(), &updated); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(updated)
|
||||
http.Redirect(w, r, "/items", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) PostToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it.Active = !it.Active
|
||||
if err := a.Store.SetItemActive(r.Context(), id, it.Active); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.Scheduler.SyncItem(*it)
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
a.Scheduler.RemoveItem(id)
|
||||
render(w, r, templates.EmptyRow())
|
||||
}
|
||||
|
||||
func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
go a.Scheduler.RunPoll(context.Background(), *it)
|
||||
// Re-render the row immediately so HTMX has something to swap in.
|
||||
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
|
||||
}
|
||||
|
||||
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<span>%s</span>", htmlEscape(it.LastPollError))
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
66
internal/handlers/preview_cache.go
Normal file
66
internal/handlers/preview_cache.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"veola/internal/apify"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type previewKey struct {
|
||||
Queries, URL, Marketplace, ListingType, ActorIDs string
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
type previewEntry struct {
|
||||
results []apify.UnifiedResult
|
||||
source string
|
||||
stored time.Time
|
||||
}
|
||||
|
||||
type PreviewCache struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
entries map[previewKey]previewEntry
|
||||
}
|
||||
|
||||
func NewPreviewCache(ttl time.Duration) *PreviewCache {
|
||||
return &PreviewCache{
|
||||
ttl: ttl,
|
||||
entries: make(map[previewKey]previewEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PreviewCache) Get(k previewKey) ([]apify.UnifiedResult, string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[k]
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
if time.Since(e.stored) > c.ttl {
|
||||
delete(c.entries, k)
|
||||
return nil, "", false
|
||||
}
|
||||
return e.results, e.source, true
|
||||
}
|
||||
|
||||
func (c *PreviewCache) Put(k previewKey, results []apify.UnifiedResult, source string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[k] = previewEntry{results: results, source: source, stored: time.Now()}
|
||||
if len(c.entries) > 64 {
|
||||
c.evictExpired()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PreviewCache) evictExpired() {
|
||||
for k, e := range c.entries {
|
||||
if time.Since(e.stored) > c.ttl {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
internal/handlers/results.go
Normal file
145
internal/handlers/results.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"veola/internal/db"
|
||||
"veola/internal/models"
|
||||
"veola/internal/scheduler"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
const resultsPerPage = 20
|
||||
|
||||
func (a *App) GetItemResults(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
it, err := a.Store.GetItem(r.Context(), id)
|
||||
if err != nil || it == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
order := r.URL.Query().Get("order")
|
||||
if order == "" {
|
||||
order = "found_desc"
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
total, err := a.Store.CountResults(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
totalPages := (total + resultsPerPage - 1) / resultsPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: id,
|
||||
Limit: resultsPerPage,
|
||||
Offset: (page - 1) * resultsPerPage,
|
||||
Order: order,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
history, err := a.Store.ListPriceHistory(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
badge := scheduler.PickBadge(*it, history, time.Now())
|
||||
chart := buildChartJSON(history)
|
||||
|
||||
render(w, r, templates.ItemResults(templates.ItemResultsData{
|
||||
Page: a.page(r, it.Name, "items"),
|
||||
Item: *it,
|
||||
Badge: badge,
|
||||
History: history,
|
||||
Results: results,
|
||||
Page_: page,
|
||||
TotalPages: totalPages,
|
||||
Order: order,
|
||||
HistoryChartJSON: chart,
|
||||
}))
|
||||
}
|
||||
|
||||
func buildChartJSON(history []models.PricePoint) string {
|
||||
c := templates.ChartJSON{
|
||||
Labels: make([]string, 0, len(history)),
|
||||
Points: make([]float64, 0, len(history)),
|
||||
}
|
||||
for _, p := range history {
|
||||
c.Labels = append(c.Labels, p.PolledAt.Format("2006-01-02"))
|
||||
c.Points = append(c.Points, p.Price)
|
||||
}
|
||||
return templates.MustChartJSON(c)
|
||||
}
|
||||
|
||||
func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
itemID, _ := strconv.ParseInt(q.Get("item_id"), 10, 64)
|
||||
from := strings.TrimSpace(q.Get("from"))
|
||||
to := strings.TrimSpace(q.Get("to"))
|
||||
|
||||
items, err := a.Store.ListItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
names := make(map[int64]string, len(items))
|
||||
for _, it := range items {
|
||||
names[it.ID] = it.Name
|
||||
}
|
||||
|
||||
results, err := a.Store.ListResults(r.Context(), db.ResultsQuery{
|
||||
ItemID: itemID,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fromT, _ := time.Parse("2006-01-02", from)
|
||||
toT, _ := time.Parse("2006-01-02", to)
|
||||
if !toT.IsZero() {
|
||||
toT = toT.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
rows := make([]templates.ItemResultRow, 0, len(results))
|
||||
for _, res := range results {
|
||||
if !fromT.IsZero() && res.FoundAt.Before(fromT) {
|
||||
continue
|
||||
}
|
||||
if !toT.IsZero() && !res.FoundAt.Before(toT) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, templates.ItemResultRow{
|
||||
Result: res,
|
||||
ItemName: names[res.ItemID],
|
||||
})
|
||||
}
|
||||
|
||||
render(w, r, templates.GlobalResults(templates.GlobalResultsData{
|
||||
Page: a.page(r, "Results", "results"),
|
||||
Items: items,
|
||||
Results: rows,
|
||||
ItemID: itemID,
|
||||
From: from,
|
||||
To: to,
|
||||
}))
|
||||
}
|
||||
195
internal/handlers/settings.go
Normal file
195
internal/handlers/settings.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
var settingsKeys = []string{
|
||||
"apify_api_key",
|
||||
"ntfy_base_url",
|
||||
"ntfy_default_topic",
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"match_confidence_threshold",
|
||||
}
|
||||
|
||||
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)
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||
Users: users,
|
||||
}, 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)
|
||||
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))
|
||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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))
|
||||
}
|
||||
101
internal/handlers/users.go
Normal file
101
internal/handlers/users.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veola/internal/auth"
|
||||
"veola/internal/models"
|
||||
"veola/templates"
|
||||
)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
d.UserMsg = msg
|
||||
d.UserError = errMsg
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) PostCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := strings.TrimSpace(r.PostFormValue("role"))
|
||||
if role != string(models.RoleAdmin) {
|
||||
role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
switch {
|
||||
case username == "":
|
||||
a.renderSettingsWithUserMsg(w, r, "", "Username is required")
|
||||
return
|
||||
case len(password) < auth.MinPasswordLen:
|
||||
a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
existing, _ := a.Store.GetUserByUsername(r.Context(), username)
|
||||
if existing != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "User already exists")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "hash error")
|
||||
return
|
||||
}
|
||||
if _, err := a.Store.CreateUser(r.Context(), username, hash, models.Role(role)); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "Created user "+username, "")
|
||||
}
|
||||
|
||||
func (a *App) PostDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur != nil && cur.ID == id {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "You cannot delete your own account")
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeleteUser(r.Context(), id); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "User removed", "")
|
||||
}
|
||||
|
||||
func (a *App) PostResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := intParam(r, "id")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next := r.PostFormValue("new_password")
|
||||
if len(next) < auth.MinPasswordLen {
|
||||
a.renderSettingsWithUserMsg(w, r, "", fmt.Sprintf("Password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
u, err := a.Store.GetUserByID(r.Context(), id)
|
||||
if err != nil || u == nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "User not found")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(next)
|
||||
if err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", "hash error")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserPassword(r.Context(), id, hash); err != nil {
|
||||
a.renderSettingsWithUserMsg(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
a.renderSettingsWithUserMsg(w, r, "Password reset for "+u.Username, "")
|
||||
}
|
||||
Reference in New Issue
Block a user