fix(pricelists): tolerate restricted DB grants and use embedded assets only

This commit is contained in:
Mikhail Chusavitin
2026-02-24 15:09:12 +03:00
parent 1906a74759
commit 8d7fab39b4
6 changed files with 40 additions and 69 deletions

View File

@@ -88,6 +88,8 @@ Database: `RFQ_LOG`
| `qt_categories` | Component categories | SELECT | | `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT | | `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT | | `qt_pricelist_items` | Pricelist line items | SELECT |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE | | `qt_configurations` | Saved configurations | SELECT, INSERT, UPDATE |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE | | `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_local_migrations` | Migration catalog | SELECT only | | `qt_client_local_migrations` | Migration catalog | SELECT only |
@@ -102,6 +104,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%'; GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
@@ -123,6 +127,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
@@ -133,6 +139,8 @@ FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%'; SHOW GRANTS FOR 'quote_user'@'%';
``` ```
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%'). **Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
--- ---

View File

@@ -557,11 +557,11 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New() router := gin.New()
router.Use(gin.Recovery()) router.Use(gin.Recovery())
staticPath := filepath.Join("web", "static") if staticFS, err := qfassets.StaticFS(); err == nil {
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS)) router.StaticFS("/static", http.FS(staticFS))
} else {
slog.Error("failed to load embedded static assets", "error", err)
os.Exit(1)
} }
// Setup routes only // Setup routes only
@@ -847,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.Use(middleware.OfflineDetector(connMgr, local)) router.Use(middleware.OfflineDetector(connMgr, local))
// Static files (use filepath.Join for Windows compatibility) // Static files (use filepath.Join for Windows compatibility)
staticPath := filepath.Join("web", "static") if staticFS, err := qfassets.StaticFS(); err == nil {
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS)) router.StaticFS("/static", http.FS(staticFS))
} else {
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
} }
// Health check // Health check

View File

@@ -6,8 +6,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@@ -28,7 +26,7 @@ type SetupHandler struct {
restartSig chan struct{} restartSig chan struct{}
} }
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
// Load setup template (standalone, no base needed) // Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
var tmpl *template.Template var tmpl *template.Template
var err error var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() { tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err) return nil, fmt.Errorf("parsing setup template: %w", err)
} }

View File

@@ -6,8 +6,6 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
stdsync "sync" stdsync "sync"
"time" "time"
@@ -32,16 +30,9 @@ type SyncHandler struct {
} }
// NewSyncHandler creates a new sync handler // NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) { func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -2,8 +2,6 @@ package handlers
import ( import (
"html/template" "html/template"
"os"
"path/filepath"
"strconv" "strconv"
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
@@ -17,7 +15,7 @@ type WebHandler struct {
componentService *services.ComponentService componentService *services.ComponentService
} }
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) { func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
} }
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base // Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"} simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
for _, page := range simplePages { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template var tmpl *template.Template
var err error var err error
if useDisk { tmpl, err = template.New("").Funcs(funcMap).ParseFS(
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath) qfassets.TemplatesFS,
} else { "web/templates/base.html",
tmpl, err = template.New("").Funcs(funcMap).ParseFS( "web/templates/"+page,
qfassets.TemplatesFS, )
"web/templates/base.html",
"web/templates/"+page,
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
} }
// Index page needs components_list.html as well // Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html")
var indexTmpl *template.Template var indexTmpl *template.Template
var err error var err error
if useDisk { indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath) qfassets.TemplatesFS,
} else { "web/templates/base.html",
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS( "web/templates/index.html",
qfassets.TemplatesFS, "web/templates/components_list.html",
"web/templates/base.html", )
"web/templates/index.html",
"web/templates/components_list.html",
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Load partial templates (no base needed) // Load partial templates (no base needed)
partials := []string{"components_list.html"} partials := []string{"components_list.html"}
for _, partial := range partials { for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial)
var tmpl *template.Template var tmpl *template.Template
var err error var err error
if useDisk { tmpl, err = template.New("").Funcs(funcMap).ParseFS(
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath) qfassets.TemplatesFS,
} else { "web/templates/"+partial,
tmpl, err = template.New("").Funcs(funcMap).ParseFS( )
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -245,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
items[i].Category = strings.TrimSpace(items[i].LotCategory) items[i].Category = strings.TrimSpace(items[i].LotCategory)
} }
// Stock/partnumber enrichment is optional for pricelist item listing.
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
if err := r.enrichItemsWithStock(items); err != nil { if err := r.enrichItemsWithStock(items); err != nil {
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err) slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
} }
return items, total, nil return items, total, nil