diff --git a/.gitignore b/.gitignore index d1bd182..5f01af6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ config.toml *.swp .idea/ .vscode/ + +# Debug log output from `-debug` runs +veola-debug.log +*.log diff --git a/deploy/veola.service b/deploy/veola.service new file mode 100644 index 0000000..482c816 --- /dev/null +++ b/deploy/veola.service @@ -0,0 +1,52 @@ +[Unit] +Description=Veola price tracker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# --- Edit these for your host --------------------------------------------- +# User must be able to read config.toml and write WorkingDirectory (sqlite WAL). +User=veola +Group=veola +WorkingDirectory=/var/lib/veola +ExecStart=/usr/local/bin/veola-bin -config /etc/veola/config.toml +# -------------------------------------------------------------------------- + +Restart=on-failure +RestartSec=5s +# SIGINT triggers the graceful-shutdown path in main.go (matches Ctrl-C). +KillSignal=SIGINT +TimeoutStopSec=45s + +# Hardening. Veola only needs to read its config, write its sqlite db, and +# reach the network. Everything else can be locked down. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +CapabilityBoundingSet= +AmbientCapabilities= +# Allow writes only to the sqlite db directory. +ReadWritePaths=/var/lib/veola +UMask=0027 + +[Install] +WantedBy=multi-user.target diff --git a/internal/apify/types.go b/internal/apify/types.go index 90a70c4..4c6876e 100644 --- a/internal/apify/types.go +++ b/internal/apify/types.go @@ -2,10 +2,70 @@ package apify import ( "encoding/json" + "log/slog" "strconv" "strings" + "time" ) +// jstLocation is the timezone ZenMarket reports Yahoo Auctions JP end times +// in when the value lacks an explicit offset. Falls back to UTC if the +// embedded tzdata lookup fails (main.go imports time/tzdata so in practice +// this always resolves). +var jstLocation = func() *time.Location { + loc, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + return time.UTC + } + return loc +}() + +// yahooEndingDateLayouts is the ordered list of layouts attempted when +// parsing ZenMarket's `ending_date`. Some entries use Local-as-JST (no +// offset in the string) — see parseYahooEndingDate for the routing. +var yahooEndingDateLayouts = []struct { + layout string + jst bool // true: parse as JST; false: parse with offset/zone from string +}{ + {time.RFC3339, false}, + {"2006-01-02T15:04:05", true}, + {"2006-01-02 15:04:05", true}, + {"2006/01/02 15:04:05", true}, + {"2006/01/02 15:04", true}, + {"2006-01-02 15:04", true}, +} + +// parseYahooEndingDate is a permissive parser for ZenMarket's ending_date +// field. The actor's output format has shifted over time and isn't documented; +// rather than wedging on one layout, try the known shapes in order and treat +// anything without an explicit zone as JST (Yahoo Auctions runs in Japan). +// Returns nil + logs a warning when nothing parses, so operators see the raw +// value and can extend this list. +func parseYahooEndingDate(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + for _, l := range yahooEndingDateLayouts { + var ( + t time.Time + err error + ) + if l.jst { + t, err = time.ParseInLocation(l.layout, s, jstLocation) + } else { + t, err = time.Parse(l.layout, s) + } + if err == nil { + slog.Debug("yahoo ending_date parsed", + "raw", s, "layout", l.layout, "jst_assumed", l.jst, "parsed", t.UTC().Format(time.RFC3339)) + return &t + } + } + slog.Warn("yahoo ending_date: no layout matched", "raw", s) + return nil +} + // ActiveListingInput is the input schema for `automation-lab/ebay-scraper`. // The actor accepts keyword searches and standard filters; it targets // ebay.com only (no per-marketplace routing in the actor itself), so @@ -145,6 +205,10 @@ type UnifiedResult struct { // MatchedQuery records which alias from the item's query list produced // this row. Empty for URL-only items or rows from non-search sources. MatchedQuery string + // EndsAt is the auction end time, if known. Auction-format eBay listings + // and Yahoo Auctions JP listings populate this; fixed-price listings + // and most Apify scraper outputs leave it nil. + EndsAt *time.Time } // Decode unmarshals a list of raw JSON items into UnifiedResult slices using @@ -197,8 +261,10 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) { } case SourceYahooJP: for _, raw := range items { + slog.Debug("yahoo raw item", "json", string(raw)) var r YahooAuctionsJPResult if err := json.Unmarshal(raw, &r); err != nil { + slog.Debug("yahoo decode failed", "err", err, "json", string(raw)) continue } img := "" @@ -213,6 +279,7 @@ func Decode(items []json.RawMessage, source string) ([]UnifiedResult, error) { Store: "yahoo-auctions-jp (via zenmarket)", ImageURL: img, Source: source, + EndsAt: parseYahooEndingDate(r.EndingDate), }) } case SourceMercariJP: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6c4112f..945bf9a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -73,6 +73,19 @@ func CheckPassword(hash, plain string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil } +// dummyHash is a valid bcrypt hash of a throwaway value. It exists only so a +// login attempt for a non-existent user can still run a real bcrypt +// comparison, equalizing response time and closing the user-enumeration +// timing oracle. +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("veola-timing-equalizer"), BcryptCost) + +// EqualizeLoginTiming performs a bcrypt comparison against a throwaway hash so +// that a missing username costs roughly the same wall-clock time as a wrong +// password. Call it on the no-such-user branch of login. +func EqualizeLoginTiming() { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte("veola-timing-equalizer-x")) +} + // LogIn writes the user id into the session and rotates the token. func (m *Manager) LogIn(ctx context.Context, userID int64) error { if err := m.Sessions.RenewToken(ctx); err != nil { diff --git a/internal/db/db.go b/internal/db/db.go index ff82b71..23806b2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -37,6 +37,22 @@ func Open(path string) (*sql.DB, error) { conn.Close() return nil, err } + if err := addColumnIfMissing(conn, "items", "condition", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "items", "region", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "items", "best_price_currency", "TEXT"); err != nil { + conn.Close() + return nil, err + } + if err := addColumnIfMissing(conn, "results", "ends_at", "DATETIME"); err != nil { + conn.Close() + return nil, err + } return conn, nil } diff --git a/internal/db/queries.go b/internal/db/queries.go index 94c9fb4..999902c 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -274,20 +274,20 @@ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) INSERT INTO items ( name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, - listing_type, + listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, - active, best_price, best_price_store, best_price_url, best_price_image_url, + active, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, last_polled_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, it.Name, s.enc(it.SearchQuery), nullStr(it.URL), nullStr(it.Category), nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), - nullStr(it.ListingType), + nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), - nullFloat(it.BestPrice), nullStr(it.BestPriceStore), + nullFloat(it.BestPrice), nullStr(it.BestPriceCurrency), nullStr(it.BestPriceStore), nullStr(s.enc(it.BestPriceURL)), nullStr(s.enc(it.BestPriceImageURL)), nullStr(s.enc(it.BestPriceTitle)), nullTime(it.LastPolledAt), ) @@ -310,7 +310,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error { name = ?, search_query = ?, url = ?, category = ?, target_price = ?, ntfy_topic = ?, ntfy_priority = ?, poll_interval_minutes = ?, include_out_of_stock = ?, min_price = ?, exclude_keywords = ?, - listing_type = ?, + listing_type = ?, condition = ?, region = ?, actor_active = ?, actor_sold = ?, actor_price_compare = ?, use_price_comparison = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -319,7 +319,7 @@ func (s *Store) UpdateItem(ctx context.Context, it *models.Item) error { nullFloat(it.TargetPrice), s.enc(it.NtfyTopic), it.NtfyPriority, it.PollIntervalMinutes, boolToInt(it.IncludeOutOfStock), nullFloat(it.MinPrice), nullStr(s.enc(it.ExcludeKeywords)), - nullStr(it.ListingType), + nullStr(it.ListingType), nullStr(it.Condition), nullStr(it.Region), nullStr(it.ActorActive), nullStr(it.ActorSold), nullStr(it.ActorPriceCompare), boolToInt(it.UsePriceComparison), boolToInt(it.Active), it.ID, @@ -491,11 +491,12 @@ func (s *Store) ListCategories(ctx context.Context) ([]string, error) { // UpdateItemPollResult writes best-price fields, last_polled_at, last_poll_error. func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models.Item, errMsg string) error { var ( - bestPrice sql.NullFloat64 - bestStore, bestURL, bestImage, bestTitle, errField sql.NullString + bestPrice sql.NullFloat64 + bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField sql.NullString ) if best != nil { bestPrice = nullFloat(best.BestPrice) + bestCurrency = nullStr(best.BestPriceCurrency) bestStore = nullStr(best.BestPriceStore) bestURL = nullStr(s.enc(best.BestPriceURL)) bestImage = nullStr(s.enc(best.BestPriceImageURL)) @@ -506,20 +507,20 @@ func (s *Store) UpdateItemPollResult(ctx context.Context, id int64, best *models } _, err := s.DB.ExecContext(ctx, ` UPDATE items SET - best_price = ?, best_price_store = ?, best_price_url = ?, + best_price = ?, best_price_currency = ?, best_price_store = ?, best_price_url = ?, best_price_image_url = ?, best_price_title = ?, last_polled_at = CURRENT_TIMESTAMP, last_poll_error = ? WHERE id = ? - `, bestPrice, bestStore, bestURL, bestImage, bestTitle, errField, id) + `, bestPrice, bestCurrency, bestStore, bestURL, bestImage, bestTitle, errField, id) return err } const itemSelect = ` SELECT id, name, search_query, url, category, target_price, ntfy_topic, ntfy_priority, poll_interval_minutes, include_out_of_stock, min_price, exclude_keywords, - listing_type, + listing_type, condition, region, actor_active, actor_sold, actor_price_compare, use_price_comparison, - active, last_polled_at, last_poll_error, best_price, best_price_store, + active, last_polled_at, last_poll_error, best_price, best_price_currency, best_price_store, best_price_url, best_price_image_url, best_price_title, created_at, updated_at FROM items ` @@ -532,10 +533,10 @@ func scanItem(r rowScanner) (*models.Item, error) { var ( it models.Item searchQuery, urlS, category, listingType sql.NullString - excludeKw sql.NullString + excludeKw, condition, region sql.NullString actorA, actorS, actorP sql.NullString ntfyTopic, lastPollErr sql.NullString - bestStore, bestURL, bestImage, bestTitle sql.NullString + bestCurrency, bestStore, bestURL, bestImage, bestTitle sql.NullString targetPrice, minPrice, bestPrice sql.NullFloat64 includeOOS, usePC, active int lastPolledAt sql.NullTime @@ -543,9 +544,9 @@ func scanItem(r rowScanner) (*models.Item, error) { if err := r.Scan( &it.ID, &it.Name, &searchQuery, &urlS, &category, &targetPrice, &ntfyTopic, &it.NtfyPriority, &it.PollIntervalMinutes, &includeOOS, &minPrice, &excludeKw, - &listingType, + &listingType, &condition, ®ion, &actorA, &actorS, &actorP, &usePC, - &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestStore, + &active, &lastPolledAt, &lastPollErr, &bestPrice, &bestCurrency, &bestStore, &bestURL, &bestImage, &bestTitle, &it.CreatedAt, &it.UpdatedAt, ); err != nil { return nil, err @@ -556,11 +557,14 @@ func scanItem(r rowScanner) (*models.Item, error) { it.URL = urlS.String it.Category = category.String it.ListingType = listingType.String + it.Condition = condition.String + it.Region = region.String it.ActorActive = actorA.String it.ActorSold = actorS.String it.ActorPriceCompare = actorP.String it.NtfyTopic = ntfyTopic.String it.LastPollError = lastPollErr.String + it.BestPriceCurrency = bestCurrency.String it.BestPriceStore = bestStore.String it.BestPriceURL = bestURL.String it.BestPriceImageURL = bestImage.String @@ -589,13 +593,14 @@ func (s *Store) decryptItem(it *models.Item) *models.Item { func (s *Store) InsertResult(ctx context.Context, r *models.Result) (int64, error) { res, err := s.DB.ExecContext(ctx, ` - INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO results (item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) `, r.ItemID, s.enc(r.Title), nullFloat(r.Price), r.Currency, nullStr(r.URL), nullStr(r.Source), s.enc(r.ImageURL), nullStr(s.enc(r.MatchedQuery)), boolToInt(r.Alerted), + nullTime(r.EndsAt), ) if err != nil { return 0, err @@ -624,11 +629,28 @@ func (s *Store) MarkResultAlerted(ctx context.Context, id int64) error { return err } +// BackfillResultEndsAt sets ends_at on an existing result row that's currently +// missing one. Used during polling: when a known auction listing reappears in +// a poll result, we still want its end time recorded even though the row +// itself isn't being re-inserted (URL dedup). +func (s *Store) BackfillResultEndsAt(ctx context.Context, itemID int64, urlStr string, endsAt time.Time) error { + if urlStr == "" { + return nil + } + _, err := s.DB.ExecContext(ctx, + `UPDATE results SET ends_at = ? WHERE item_id = ? AND url = ? AND ends_at IS NULL`, + endsAt, itemID, urlStr) + return err +} + type ResultsQuery struct { ItemID int64 // 0 = all items Limit int Offset int Order string // "price_asc", "price_desc", "found_desc" (default), "found_asc" + // ExcludeEnded drops rows whose ends_at is in the past. Fixed-price + // listings (ends_at IS NULL) are kept regardless: they don't expire. + ExcludeEnded bool } func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Result, error) { @@ -646,14 +668,22 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul limit = 20 } args := []any{} - where := "" + var conds []string if q.ItemID != 0 { - where = `WHERE item_id = ?` + conds = append(conds, `item_id = ?`) args = append(args, q.ItemID) } + if q.ExcludeEnded { + conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) + args = append(args, time.Now().UTC()) + } + where := "" + if len(conds) > 0 { + where = `WHERE ` + strings.Join(conds, ` AND `) + } args = append(args, limit, q.Offset) rows, err := s.DB.QueryContext(ctx, fmt.Sprintf(` - SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at + SELECT id, item_id, title, price, currency, url, source, image_url, matched_query, alerted, found_at, ends_at FROM results %s ORDER BY %s LIMIT ? OFFSET ? `, where, order), args...) if err != nil { @@ -667,8 +697,9 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul title, urlS, source, imageS, matchQ sql.NullString price sql.NullFloat64 alerted int + endsAt sql.NullTime ) - if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt); err != nil { + if err := rows.Scan(&r.ID, &r.ItemID, &title, &price, &r.Currency, &urlS, &source, &imageS, &matchQ, &alerted, &r.FoundAt, &endsAt); err != nil { return nil, err } r.Title = s.dec(title.String) @@ -678,19 +709,76 @@ func (s *Store) ListResults(ctx context.Context, q ResultsQuery) ([]models.Resul r.MatchedQuery = s.dec(matchQ.String) r.Price = ptrFloat(price) r.Alerted = alerted != 0 + r.EndsAt = ptrTime(endsAt) out = append(out, r) } return out, rows.Err() } -func (s *Store) CountResults(ctx context.Context, itemID int64) (int, error) { +// EndingSoon is a compact projection for the "ending soon" strip: the single +// nearest-to-end auction across the user's results, with enough context to +// render and link to it. +type EndingSoon struct { + ItemID int64 + ItemName string + Title string + URL string + EndsAt time.Time +} + +// NextEndingResult returns the soonest-ending result whose ends_at lies in the +// window (now, now+within]. If itemID is 0 the search spans all items; nil is +// returned when no auction falls inside the window. +func (s *Store) NextEndingResult(ctx context.Context, itemID int64, within time.Duration) (*EndingSoon, error) { + now := time.Now().UTC() + cutoff := now.Add(within) + q := `SELECT r.item_id, r.title, r.url, r.ends_at, i.name + FROM results r JOIN items i ON r.item_id = i.id + WHERE r.ends_at IS NOT NULL AND r.ends_at > ? AND r.ends_at <= ?` + args := []any{now, cutoff} + if itemID != 0 { + q += ` AND r.item_id = ?` + args = append(args, itemID) + } + q += ` ORDER BY r.ends_at ASC LIMIT 1` + row := s.DB.QueryRowContext(ctx, q, args...) + var ( + e EndingSoon + title sql.NullString + urlS sql.NullString + endsAt time.Time + ) + if err := row.Scan(&e.ItemID, &title, &urlS, &endsAt, &e.ItemName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + e.Title = s.dec(title.String) + e.URL = urlS.String + e.EndsAt = endsAt + return &e, nil +} + +// CountResults returns the row count matching the same filters ListResults +// applies. Pagination relies on this matching the visible list, so it must +// honor ExcludeEnded too. +func (s *Store) CountResults(ctx context.Context, itemID int64, excludeEnded bool) (int, error) { var n int q := `SELECT COUNT(*) FROM results` args := []any{} + var conds []string if itemID != 0 { - q += ` WHERE item_id = ?` + conds = append(conds, `item_id = ?`) args = append(args, itemID) } + if excludeEnded { + conds = append(conds, `(ends_at IS NULL OR ends_at > ?)`) + args = append(args, time.Now().UTC()) + } + if len(conds) > 0 { + q += ` WHERE ` + strings.Join(conds, ` AND `) + } err := s.DB.QueryRowContext(ctx, q, args...).Scan(&n) return n, err } diff --git a/internal/db/schema.sql b/internal/db/schema.sql index fb9aac5..e8d2cf9 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS items ( min_price REAL, exclude_keywords TEXT, listing_type TEXT, + condition TEXT, + region TEXT, actor_active TEXT, actor_sold TEXT, actor_price_compare TEXT, @@ -31,6 +33,7 @@ CREATE TABLE IF NOT EXISTS items ( last_polled_at DATETIME, last_poll_error TEXT, best_price REAL, + best_price_currency TEXT, best_price_store TEXT, best_price_url TEXT, best_price_image_url TEXT, @@ -61,7 +64,8 @@ CREATE TABLE IF NOT EXISTS results ( image_url TEXT, matched_query TEXT, alerted INTEGER DEFAULT 0, - found_at DATETIME DEFAULT CURRENT_TIMESTAMP + found_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ends_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_results_item ON results(item_id, found_at DESC); diff --git a/internal/ebay/client.go b/internal/ebay/client.go index 8046f74..de90b03 100644 --- a/internal/ebay/client.go +++ b/internal/ebay/client.go @@ -149,6 +149,8 @@ type browseItemSummary struct { Seller struct { Username string `json:"username"` } `json:"seller"` + // itemEndDate is present only on auction-format listings. + ItemEndDate string `json:"itemEndDate"` } // Search runs one item_summary/search call and returns normalized listings. @@ -174,8 +176,20 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) q := url.Values{} q.Set("q", query) q.Set("limit", strconv.Itoa(limit)) + // The Browse API "filter" parameter takes a comma-separated list of + // filter clauses; assemble whichever ones the caller requested. + var filters []string if f := buyingOptionsFilter(p.ListingType); f != "" { - q.Set("filter", f) + filters = append(filters, f) + } + if f := conditionIDsFilter(p.Condition); f != "" { + filters = append(filters, f) + } + if f := itemLocationFilter(p.Region); f != "" { + filters = append(filters, f) + } + if len(filters) > 0 { + q.Set("filter", strings.Join(filters, ",")) } reqURL := c.ends.browse + "/item_summary/search?" + q.Encode() @@ -214,6 +228,12 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) if s.Seller.Username != "" { store = "ebay (" + s.Seller.Username + ")" } + var endsAt *time.Time + if s.ItemEndDate != "" { + if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil { + endsAt = &t + } + } out = append(out, Listing{ Title: s.Title, Price: price, @@ -221,6 +241,7 @@ func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) URL: s.ItemWebURL, Store: store, ImageURL: img, + EndsAt: endsAt, }) } return out, nil diff --git a/internal/ebay/ebay_test.go b/internal/ebay/ebay_test.go index 9d7a7d0..a734132 100644 --- a/internal/ebay/ebay_test.go +++ b/internal/ebay/ebay_test.go @@ -43,3 +43,35 @@ func TestBuyingOptionsFilter(t *testing.T) { } } } + +func TestConditionIDsFilter(t *testing.T) { + cases := map[string]string{ + "": "", + "anything": "", + "new": "conditionIds:{1000|1500}", + "NEW": "conditionIds:{1000|1500}", + " used ": "conditionIds:{3000}", + "refurbished": "conditionIds:{2000|2010|2020|2030|2500}", + "parts": "conditionIds:{7000}", + } + for in, want := range cases { + if got := conditionIDsFilter(in); got != want { + t.Errorf("conditionIDsFilter(%q) = %q, want %q", in, got, want) + } + } +} + +func TestItemLocationFilter(t *testing.T) { + cases := map[string]string{ + "": "", + " ": "", + "us": "itemLocationCountry:US", + "GB": "itemLocationCountry:GB", + " jp ": "itemLocationCountry:JP", + } + for in, want := range cases { + if got := itemLocationFilter(in); got != want { + t.Errorf("itemLocationFilter(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/ebay/types.go b/internal/ebay/types.go index 07d18bf..44d624a 100644 --- a/internal/ebay/types.go +++ b/internal/ebay/types.go @@ -1,6 +1,9 @@ package ebay -import "strings" +import ( + "strings" + "time" +) // SearchParams is the input to a single Browse API item_summary/search call. // It is provider-specific and is carried as the opaque input payload of a @@ -14,6 +17,14 @@ type SearchParams struct { // ListingType is Veola's vocabulary ("all", "bin"/"buy_it_now", // "auction"); it is mapped to a buyingOptions filter. ListingType string + // Condition is Veola's condition vocabulary ("new", "used", + // "refurbished", "parts"); it is mapped to a conditionIds filter. Empty + // means no condition filter. + Condition string + // Region is an ISO 3166-1 alpha-2 country code constraining item + // location (mapped to the itemLocationCountry filter). Empty means no + // location filter. + Region string // Limit caps the number of results requested (Browse API max is 200). Limit int } @@ -28,6 +39,10 @@ type Listing struct { URL string Store string ImageURL string + // EndsAt is the auction end time as reported by the Browse API + // (itemEndDate). Nil for fixed-price ("Buy It Now") listings, which + // don't have one. + EndsAt *time.Time } // MarketplaceID maps a Veola marketplace string (e.g. "ebay.com", @@ -92,3 +107,34 @@ func buyingOptionsFilter(listingType string) string { return "" } } + +// conditionIDsFilter maps Veola's condition vocabulary to a Browse API +// "conditionIds" filter clause. Each Veola value expands to the set of eBay +// condition IDs that belong to it (e.g. "new" covers both brand-new and +// new-other). An empty or unknown value yields no filter. +func conditionIDsFilter(condition string) string { + var ids string + switch strings.ToLower(strings.TrimSpace(condition)) { + case "new": + ids = "1000|1500" + case "used": + ids = "3000" + case "refurbished": + ids = "2000|2010|2020|2030|2500" + case "parts": + ids = "7000" + default: + return "" + } + return "conditionIds:{" + ids + "}" +} + +// itemLocationFilter maps an ISO 3166-1 alpha-2 country code to a Browse API +// "itemLocationCountry" filter clause. An empty value yields no filter. +func itemLocationFilter(region string) string { + r := strings.ToUpper(strings.TrimSpace(region)) + if r == "" { + return "" + } + return "itemLocationCountry:" + r +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 9b97cb6..57004a7 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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", ""), diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index fcdd532..c396eb1 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -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 } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2852a15..c1b60f5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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 { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go new file mode 100644 index 0000000..a3f8e46 --- /dev/null +++ b/internal/handlers/handlers_test.go @@ -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(), "