Harden local runtime safety and error handling

This commit is contained in:
Mikhail Chusavitin
2026-03-15 16:28:32 +03:00
parent f0e6bba7e9
commit c964d66e64
25 changed files with 726 additions and 245 deletions

View File

@@ -25,6 +25,7 @@ QuoteForge is currently a **single-user thick client** bound to `localhost`.
- The local HTTP/UI layer is not treated as a multi-user security boundary. - The local HTTP/UI layer is not treated as a multi-user security boundary.
- RBAC is not part of the active product contract for the local client. - RBAC is not part of the active product contract for the local client.
- The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup. - The authoritative authentication boundary is the remote sync server and its DB credentials captured during setup.
- Runtime startup must reject non-loopback `server.host` values; remote bind is not a supported deployment mode.
- If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release. - If the app is ever exposed beyond `localhost`, auth/RBAC must be reintroduced as an enforced perimeter before release.
### Price Freshness Indicators ### Price Freshness Indicators

View File

@@ -78,6 +78,7 @@
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field: `POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
- `file` — vendor configurator export in `CFXML` format - `file` — vendor configurator export in `CFXML` format
- max request file size: `1 GiB`; oversized uploads are rejected before parsing
### Sync ### Sync

View File

@@ -41,7 +41,7 @@ Rules:
```yaml ```yaml
server: server:
host: "0.0.0.0" host: "127.0.0.1"
port: 8080 port: 8080
mode: "release" # release | debug mode: "release" # release | debug
@@ -54,6 +54,9 @@ backup:
time: "00:00" # HH:MM in local time time: "00:00" # HH:MM in local time
``` ```
`server.host` must stay on loopback (`127.0.0.1`, `localhost`, or `::1`).
QuoteForge startup rejects non-loopback bind addresses because the local client has no auth/RBAC perimeter.
--- ---
## Environment Variables ## Environment Variables

View File

@@ -5,8 +5,7 @@
Automatic rotating ZIP backup system for local data. Automatic rotating ZIP backup system for local data.
**What is included in each archive:** **What is included in each archive:**
- SQLite DB (`qfs.db`) - Consistent SQLite snapshot stored as `qfs.db`
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
- `config.yaml` if present - `config.yaml` if present
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip` **Archive name format:** `qfs-backp-YYYY-MM-DD.zip`
@@ -78,6 +77,7 @@ type BackupConfig struct {
## Implementation Notes ## Implementation Notes
- `backup.time` is in **local time** without timezone offset parsing - `backup.time` is in **local time** without timezone offset parsing
- Backup captures a consistent SQLite snapshot via `VACUUM INTO` before zipping; it does not archive live `-wal` / `-shm` sidecars directly
- `.period.json` is the marker that prevents duplicate backups within the same period - `.period.json` is the marker that prevents duplicate backups within the same period
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker - Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
- When changing naming or retention: update both the filename logic and the prune logic together - When changing naming or retention: update both the filename logic and the prune logic together

View File

@@ -171,6 +171,7 @@ This import path must convert one external workspace into one QuoteForge project
- One top-level configuration group inside the workspace = one QuoteForge configuration. - One top-level configuration group inside the workspace = one QuoteForge configuration.
- Software rows are **not** imported as standalone configurations. - Software rows are **not** imported as standalone configurations.
- All software rows must be attached to the configuration group they belong to. - All software rows must be attached to the configuration group they belong to.
- Upload guardrail: the incoming `CFXML` file must not exceed `1 GiB`; larger payloads are rejected before XML parsing.
### Configuration Grouping ### Configuration Grouping

View File

@@ -64,3 +64,28 @@ logging:
t.Fatalf("migrated config did not preserve logging level:\n%s", text) t.Fatalf("migrated config did not preserve logging level:\n%s", text)
} }
} }
func TestEnsureLoopbackServerHost(t *testing.T) {
t.Parallel()
cases := []struct {
host string
wantErr bool
}{
{host: "127.0.0.1", wantErr: false},
{host: "localhost", wantErr: false},
{host: "::1", wantErr: false},
{host: "0.0.0.0", wantErr: true},
{host: "192.168.1.10", wantErr: true},
}
for _, tc := range cases {
err := ensureLoopbackServerHost(tc.host)
if tc.wantErr && err == nil {
t.Fatalf("expected error for host %q", tc.host)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"math" "math"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -43,11 +44,16 @@ import (
// Version is set via ldflags during build // Version is set via ldflags during build
var Version = "dev" var Version = "dev"
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")
const backgroundSyncInterval = 5 * time.Minute const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second const onDemandPullCooldown = 30 * time.Second
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать" const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
var vendorImportMaxBytes int64 = 1 << 30
const vendorImportMultipartOverheadBytes int64 = 8 << 20
func main() { func main() {
showStartupConsoleWarning() showStartupConsoleWarning()
@@ -142,6 +148,10 @@ func main() {
} }
} }
setConfigDefaults(cfg) setConfigDefaults(cfg)
if err := ensureLoopbackServerHost(cfg.Server.Host); err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil { if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err) slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1) os.Exit(1)
@@ -342,6 +352,35 @@ func setConfigDefaults(cfg *config.Config) {
} }
} }
func ensureLoopbackServerHost(host string) error {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return fmt.Errorf("server.host must not be empty")
}
if strings.EqualFold(trimmed, "localhost") {
return nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil && ip.IsLoopback() {
return nil
}
return fmt.Errorf("QuoteForge local client must bind to localhost only")
}
func vendorImportBodyLimit() int64 {
return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
}
func isVendorImportTooLarge(fileSize int64, err error) bool {
if fileSize > vendorImportMaxBytes {
return true
}
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
}
func ensureDefaultConfigFile(configPath string) error { func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" { if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty") return fmt.Errorf("config path is empty")
@@ -747,6 +786,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local) vendorSpecHandler := handlers.NewVendorSpecHandler(local)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -766,6 +806,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Router // Router
router := gin.New() router := gin.New()
router.MaxMultipartMemory = vendorImportBodyLimit()
router.Use(gin.Recovery()) router.Use(gin.Recovery())
router.Use(requestLogger()) router.Use(requestLogger())
router.Use(middleware.CORS()) router.Use(middleware.CORS())
@@ -786,17 +827,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
}) })
// Restart endpoint (for development purposes) // Restart endpoint is intentionally debug-only.
router.POST("/api/restart", func(c *gin.Context) { if cfg.Server.Mode == "debug" {
// This will cause the server to restart by exiting router.POST("/api/restart", func(c *gin.Context) {
// The restartProcess function will be called to restart the process slog.Info("Restart requested via API")
slog.Info("Restart requested via API") go func() {
go func() { time.Sleep(100 * time.Millisecond)
time.Sleep(100 * time.Millisecond) restartProcess()
restartProcess() }()
}() c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) })
}) }
// DB status endpoint // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
@@ -928,7 +969,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search) cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -949,7 +990,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
@@ -958,13 +999,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("", func(c *gin.Context) { configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -974,12 +1015,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/preview-article", func(c *gin.Context) { configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := configService.BuildArticlePreview(&req) result, err := configService.BuildArticlePreview(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1002,7 +1043,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -1010,13 +1051,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1027,7 +1068,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.DELETE("/:uuid", func(c *gin.Context) { configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil { if err := configService.DeleteNoAuth(uuid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "archived"}) c.JSON(http.StatusOK, gin.H{"message": "archived"})
@@ -1037,7 +1078,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid) config, err := configService.ReactivateNoAuth(uuid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1052,13 +1093,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.RenameNoAuth(uuid, req.Name) config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1072,7 +1113,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
FromVersion int `json:"from_version"` FromVersion int `json:"from_version"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -1082,7 +1123,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1093,7 +1134,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.RefreshPricesNoAuth(uuid) config, err := configService.RefreshPricesNoAuth(uuid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1105,20 +1146,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ProjectUUID string `json:"project_uuid"` ProjectUUID string `json:"project_uuid"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1147,7 +1188,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrInvalidVersionNumber): case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1175,7 +1216,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrConfigVersionNotFound): case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1190,7 +1231,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Note string `json:"note"` Note string `json:"note"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if req.TargetVersion <= 0 { if req.TargetVersion <= 0 {
@@ -1208,7 +1249,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrVersionConflict): case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"}) c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1243,12 +1284,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ServerCount int `json:"server_count" binding:"required,min=1"` ServerCount int `json:"server_count" binding:"required,min=1"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.UpdateServerCount(uuid, req.ServerCount) config, err := configService.UpdateServerCount(uuid, req.ServerCount)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1293,7 +1334,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1427,7 +1468,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.GET("/all", func(c *gin.Context) { projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1457,7 +1498,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("", func(c *gin.Context) { projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if strings.TrimSpace(req.Code) == "" { if strings.TrimSpace(req.Code) == "" {
@@ -1468,9 +1509,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) respondError(c, http.StatusConflict, "conflict detected", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1482,11 +1523,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1496,20 +1537,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.PUT("/:uuid", func(c *gin.Context) { projects.PUT("/:uuid", func(c *gin.Context) {
var req services.UpdateProjectRequest var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) respondError(c, http.StatusConflict, "conflict detected", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1520,11 +1561,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1535,11 +1576,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1550,13 +1591,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil { if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrCannotDeleteMainVariant): case errors.Is(err, services.ErrCannotDeleteMainVariant):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1576,11 +1617,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1593,7 +1634,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
OrderedUUIDs []string `json:"ordered_uuids"` OrderedUUIDs []string `json:"ordered_uuids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if len(req.OrderedUUIDs) == 0 { if len(req.OrderedUUIDs) == 0 {
@@ -1605,9 +1646,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
} }
return return
} }
@@ -1628,7 +1669,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("/:uuid/configs", func(c *gin.Context) { projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
@@ -1636,29 +1677,42 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
}) })
projects.POST("/:uuid/vendor-import", func(c *gin.Context) { projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) if isVendorImportTooLarge(0, err) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
respondError(c, http.StatusBadRequest, "file is required", err)
return
}
if isVendorImportTooLarge(fileHeader.Size, nil) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return return
} }
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"}) respondError(c, http.StatusBadRequest, "failed to open uploaded file", err)
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(file) data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"}) respondError(c, http.StatusBadRequest, "failed to read uploaded file", err)
return
}
if int64(len(data)) > vendorImportMaxBytes {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return return
} }
if !services.IsCFXMLWorkspace(data) { if !services.IsCFXMLWorkspace(data) {
@@ -1670,9 +1724,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
} }
return return
} }
@@ -1688,14 +1742,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID) config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
@@ -1769,22 +1823,12 @@ func requestLogger() gin.HandlerFunc {
path := c.Request.URL.Path path := c.Request.URL.Path
query := c.Request.URL.RawQuery query := c.Request.URL.RawQuery
blw := &captureResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
}
c.Writer = blw
c.Next() c.Next()
latency := time.Since(start) latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
if status >= http.StatusBadRequest { if status >= http.StatusBadRequest {
responseBody := strings.TrimSpace(blw.body.String())
if len(responseBody) > 2048 {
responseBody = responseBody[:2048] + "...(truncated)"
}
errText := strings.TrimSpace(c.Errors.String()) errText := strings.TrimSpace(c.Errors.String())
slog.Error("request failed", slog.Error("request failed",
@@ -1795,7 +1839,6 @@ func requestLogger() gin.HandlerFunc {
"latency", latency, "latency", latency,
"ip", c.ClientIP(), "ip", c.ClientIP(),
"errors", errText, "errors", errText,
"response", responseBody,
) )
return return
} }
@@ -1810,22 +1853,3 @@ func requestLogger() gin.HandlerFunc {
) )
} }
} }
type captureResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *captureResponseWriter) Write(b []byte) (int, error) {
if len(b) > 0 {
_, _ = w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *captureResponseWriter) WriteString(s string) (int, error) {
if s != "" {
_, _ = w.body.WriteString(s)
}
return w.ResponseWriter.WriteString(s)
}

View File

@@ -0,0 +1,48 @@
package main
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequestLoggerDoesNotLogResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
var logBuffer bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{})))
defer slog.SetDefault(previousLogger)
router := gin.New()
router.Use(requestLogger())
router.GET("/fail", func(c *gin.Context) {
_ = c.Error(errors.New("root cause"))
c.JSON(http.StatusBadRequest, gin.H{"error": "do not log this body"})
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/fail?debug=1", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
logOutput := logBuffer.String()
if !strings.Contains(logOutput, "request failed") {
t.Fatalf("expected request failure log, got %q", logOutput)
}
if strings.Contains(logOutput, "do not log this body") {
t.Fatalf("response body leaked into logs: %q", logOutput)
}
if !strings.Contains(logOutput, "root cause") {
t.Fatalf("expected error details in logs, got %q", logOutput)
}
}

View File

@@ -3,10 +3,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
@@ -290,6 +292,88 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
} }
} }
func TestVendorImportRejectsOversizedUpload(t *testing.T) {
moveToRepoRoot(t)
prevLimit := vendorImportMaxBytes
vendorImportMaxBytes = 128
defer func() { vendorImportMaxBytes = prevLimit }()
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Import Project","code":"IMP"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
if createProjectRec.Code != http.StatusCreated {
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
}
var project models.Project
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
t.Fatalf("unmarshal project: %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "huge.xml")
if err != nil {
t.Fatalf("create form file: %v", err)
}
payload := "<CFXML>" + strings.Repeat("A", int(vendorImportMaxBytes)+1) + "</CFXML>"
if _, err := part.Write([]byte(payload)); err != nil {
t.Fatalf("write multipart payload: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/vendor-import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized upload, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "1 GiB") {
t.Fatalf("expected size limit message, got %s", rec.Body.String())
}
}
func TestCreateConfigMalformedJSONReturnsGenericError(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for malformed json, got %d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(strings.ToLower(rec.Body.String()), "unexpected eof") {
t.Fatalf("expected sanitized error body, got %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid request") {
t.Fatalf("expected generic invalid request message, got %s", rec.Body.String())
}
}
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) { func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper() t.Helper()

View File

@@ -2,7 +2,7 @@
# Copy this file to config.yaml and update values # Copy this file to config.yaml and update values
server: server:
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
port: 8080 port: 8080
mode: "release" # debug | release mode: "release" # debug | release
read_timeout: "30s" read_timeout: "30s"

View File

@@ -10,6 +10,10 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
type backupPeriod struct { type backupPeriod struct {
@@ -250,6 +254,12 @@ func pruneOldBackups(periodDir string, keep int) error {
} }
func createBackupArchive(destPath, dbPath, configPath string) error { func createBackupArchive(destPath, dbPath, configPath string) error {
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
if err != nil {
return err
}
defer cleanup()
file, err := os.Create(destPath) file, err := os.Create(destPath)
if err != nil { if err != nil {
return err return err
@@ -257,12 +267,10 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
defer file.Close() defer file.Close()
zipWriter := zip.NewWriter(file) zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil { if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
_ = zipWriter.Close() _ = zipWriter.Close()
return err return err
} }
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" { if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath) _ = addZipOptionalFile(zipWriter, configPath)
@@ -274,6 +282,77 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
return file.Sync() return file.Sync()
} }
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
if err != nil {
return "", func() {}, err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", func() {}, err
}
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
return "", func() {}, err
}
cleanup := func() {
_ = os.Remove(tempPath)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
cleanup()
return "", func() {}, err
}
sqlDB, err := db.DB()
if err != nil {
cleanup()
return "", func() {}, err
}
defer sqlDB.Close()
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
cleanup()
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
}
literalPath := strings.ReplaceAll(tempPath, "'", "''")
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
cleanup()
return "", func() {}, err
}
return tempPath, cleanup, nil
}
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
lastErr = err
if !isSQLiteBusyError(err) {
return fmt.Errorf("create sqlite snapshot: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
continue
}
return nil
}
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
}
func addZipOptionalFile(writer *zip.Writer, path string) error { func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
return nil return nil
@@ -282,6 +361,10 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
} }
func addZipFile(writer *zip.Writer, path string) error { func addZipFile(writer *zip.Writer, path string) error {
return addZipFileAs(writer, path, filepath.Base(path))
}
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
in, err := os.Open(path) in, err := os.Open(path)
if err != nil { if err != nil {
return err return err
@@ -297,7 +380,7 @@ func addZipFile(writer *zip.Writer, path string) error {
if err != nil { if err != nil {
return err return err
} }
header.Name = filepath.Base(path) header.Name = archiveName
header.Method = zip.Deflate header.Method = zip.Deflate
out, err := writer.CreateHeader(header) out, err := writer.CreateHeader(header)

View File

@@ -1,11 +1,15 @@
package appstate package appstate
import ( import (
"archive/zip"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
) )
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) { func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
@@ -13,8 +17,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil { if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write db: %v", err) t.Fatalf("write sqlite db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -36,6 +40,7 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
if _, err := os.Stat(dailyArchive); err != nil { if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err) t.Fatalf("daily archive missing: %v", err)
} }
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) } backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath) created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
@@ -57,8 +62,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil { if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write db: %v", err) t.Fatalf("write sqlite db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -95,8 +100,8 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatalf("mkdir data dir: %v", err) t.Fatalf("mkdir data dir: %v", err)
} }
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil { if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write db: %v", err) t.Fatalf("write sqlite db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write cfg: %v", err) t.Fatalf("write cfg: %v", err)
@@ -110,3 +115,43 @@ func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }
func writeTestSQLiteDB(path string) error {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
return db.Exec(`
CREATE TABLE sample_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
INSERT INTO sample_items(name) VALUES ('backup');
`).Error
}
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open archive: %v", err)
}
defer reader.Close()
found := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
found[file.Name] = true
}
for _, name := range expected {
if !found[name] {
t.Fatalf("archive %s missing %s", archivePath, name)
}
}
}

View File

@@ -180,5 +180,5 @@ func (c *Config) setDefaults() {
} }
func (c *Config) Address() string { func (c *Config) Address() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
} }

View File

@@ -49,7 +49,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
offset := (page - 1) * perPage offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }

View File

@@ -58,7 +58,7 @@ type ProjectExportOptionsRequest struct {
func (h *ExportHandler) ExportCSV(c *gin.Context) { func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -150,7 +150,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// Get config before streaming (can return JSON error) // Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, h.dbUsername) config, err := h.configService.GetByUUID(uuid, h.dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
} }
@@ -193,13 +193,13 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
} }
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -226,19 +226,19 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
var req ProjectExportOptionsRequest var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
} }
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if len(result.Configs) == 0 { if len(result.Configs) == 0 {
@@ -256,7 +256,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts) data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }

View File

@@ -25,7 +25,7 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
books, err := bookRepo.ListBooks() books, err := bookRepo.ListBooks()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -86,7 +86,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage) items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }

View File

@@ -34,7 +34,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists() localPLs, err := h.localDB.GetLocalPricelists()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if source != "" { if source != "" {
@@ -172,13 +172,13 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
} }
var total int64 var total int64
if err := dbq.Count(&total).Error; err != nil { if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
lotNames := make([]string, len(items)) lotNames := make([]string, len(items))
@@ -241,7 +241,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
} }
items, err := h.localDB.GetLocalPricelistItems(localPL.ID) items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
lotNames := make([]string, 0, len(items)) lotNames := make([]string, 0, len(items))

View File

@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
func (h *QuoteHandler) Validate(c *gin.Context) { func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
func (h *QuoteHandler) PriceLevels(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.CalculatePriceLevels(&req) result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }

View File

@@ -0,0 +1,73 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/gin-gonic/gin"
)
func RespondError(c *gin.Context, status int, fallback string, err error) {
if err != nil {
_ = c.Error(err)
}
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
}
func clientFacingErrorMessage(status int, fallback string, err error) string {
if err == nil {
return fallback
}
if status >= 500 {
return fallback
}
if isRequestDecodeError(err) {
return fallback
}
message := strings.TrimSpace(err.Error())
if message == "" {
return fallback
}
if looksTechnicalError(message) {
return fallback
}
return message
}
func isRequestDecodeError(err error) bool {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return true
}
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &unmarshalTypeErr) {
return true
}
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
}
func looksTechnicalError(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
needles := []string{
"sql",
"gorm",
"driver",
"constraint",
"syntax error",
"unexpected eof",
"record not found",
"no such table",
"stack trace",
}
for _, needle := range needles {
if strings.Contains(lower, needle) {
return true
}
}
return false
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"encoding/json"
"testing"
)
func TestClientFacingErrorMessageKeepsDomain4xx(t *testing.T) {
t.Parallel()
got := clientFacingErrorMessage(400, "invalid request", &json.SyntaxError{Offset: 1})
if got != "invalid request" {
t.Fatalf("expected fallback for decode error, got %q", got)
}
}
func TestClientFacingErrorMessagePreservesBusinessMessage(t *testing.T) {
t.Parallel()
err := errString("main project variant cannot be deleted")
got := clientFacingErrorMessage(400, "invalid request", err)
if got != err.Error() {
t.Fatalf("expected business message, got %q", got)
}
}
func TestClientFacingErrorMessageHidesTechnical4xx(t *testing.T) {
t.Parallel()
err := errString("sql: no rows in result set")
got := clientFacingErrorMessage(404, "resource not found", err)
if got != "resource not found" {
t.Fatalf("expected fallback for technical error, got %q", got)
}
}
type errString string
func (e errString) Error() string {
return string(e)
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
@@ -12,8 +13,8 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
mysqlDriver "github.com/go-sql-driver/mysql"
gormmysql "gorm.io/driver/mysql" gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -26,6 +27,8 @@ type SetupHandler struct {
restartSig chan struct{} restartSig chan struct{}
} }
var errPermissionProbeRollback = errors.New("permission probe rollback")
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ 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 },
@@ -64,7 +67,8 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) {
tmpl := h.templates["setup.html"] tmpl := h.templates["setup.html"]
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err) _ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
} }
} }
@@ -89,49 +93,16 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
lotCount, canWrite, err := validateMariaDBConnection(dsn)
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil { if err != nil {
_ = c.Error(err)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Connection failed: %v", err), "error": "Connection check failed",
}) })
return return
} }
sqlDB, err := db.DB()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to get database handle: %v", err),
})
return
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Ping failed: %v", err),
})
return
}
// Check for required tables
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
})
return
}
// Check write permission
canWrite := testWritePermission(db)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"lot_count": lotCount, "lot_count": lotCount,
@@ -164,26 +135,21 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first // Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
if _, _, err := validateMariaDBConnection(dsn); err != nil {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{ _ = c.Error(err)
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Connection failed: %v", err), "error": "Connection check failed",
}) })
return return
} }
sqlDB, _ := db.DB()
sqlDB.Close()
// Save settings // Save settings
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil { if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Failed to save settings: %v", err), "error": "Failed to save settings",
}) })
return return
} }
@@ -232,22 +198,6 @@ func (h *SetupHandler) GetStatus(c *gin.Context) {
}) })
} }
func testWritePermission(db *gorm.DB) bool {
// Simple check: try to create a temporary table and drop it
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
// Try to create a test table
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
if err != nil {
return false
}
// Drop it immediately
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string { func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig() cfg := mysqlDriver.NewConfig()
cfg.User = user cfg.User = user
@@ -263,3 +213,47 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
} }
return cfg.FormatDSN() return cfg.FormatDSN()
} }
func validateMariaDBConnection(dsn string) (int64, bool, error) {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return 0, false, fmt.Errorf("get database handle: %w", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
}
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
return 0, false, fmt.Errorf("check required table lot: %w", err)
}
return lotCount, testSyncWritePermission(db), nil
}
func testSyncWritePermission(db *gorm.DB) bool {
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, sentinel, "setup-check").Error; err != nil {
return err
}
return errPermissionProbeRollback
})
return errors.Is(err, errPermissionProbeRollback)
}

View File

@@ -116,9 +116,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
func (h *SyncHandler) GetReadiness(c *gin.Context) { func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness() readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil { if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
if readiness == nil { if readiness == nil {
@@ -158,8 +156,9 @@ func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "internal server error",
}) })
_ = c.Error(err)
_ = readiness _ = readiness
return false return false
} }
@@ -184,8 +183,9 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "Database connection failed: " + err.Error(), "error": "database connection failed",
}) })
_ = c.Error(err)
return return
} }
@@ -194,8 +194,9 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "component sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -220,8 +221,9 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
slog.Error("pricelist sync failed", "error", err) slog.Error("pricelist sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pricelist sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -247,8 +249,9 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
slog.Error("partnumber books pull failed", "error", err) slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "partnumber books sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -295,8 +298,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pending push failed during full sync", "error", err) slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pending changes push failed: " + err.Error(), "error": "pending changes push failed",
}) })
_ = c.Error(err)
return return
} }
@@ -305,8 +309,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "Database connection failed: " + err.Error(), "error": "database connection failed",
}) })
_ = c.Error(err)
return return
} }
@@ -315,8 +320,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Component sync failed: " + err.Error(), "error": "component sync failed",
}) })
_ = c.Error(err)
return return
} }
componentsSynced = compResult.TotalSynced componentsSynced = compResult.TotalSynced
@@ -327,10 +333,11 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pricelist sync failed during full sync", "error", err) slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pricelist sync failed: " + err.Error(), "error": "pricelist sync failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
_ = c.Error(err)
return return
} }
@@ -339,11 +346,12 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("project import failed during full sync", "error", err) slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Project import failed: " + err.Error(), "error": "project import failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
}) })
_ = c.Error(err)
return return
} }
@@ -352,7 +360,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("configuration import failed during full sync", "error", err) slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Configuration import failed: " + err.Error(), "error": "configuration import failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
@@ -360,6 +368,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"projects_updated": projectsResult.Updated, "projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped, "projects_skipped": projectsResult.Skipped,
}) })
_ = c.Error(err)
return return
} }
@@ -398,8 +407,9 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
slog.Error("push pending changes failed", "error", err) slog.Error("push pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pending changes push failed",
}) })
_ = c.Error(err)
return return
} }
@@ -426,9 +436,7 @@ func (h *SyncHandler) GetPendingCount(c *gin.Context) {
func (h *SyncHandler) GetPendingChanges(c *gin.Context) { func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
changes, err := h.localDB.GetPendingChanges() changes, err := h.localDB.GetPendingChanges()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
@@ -445,8 +453,9 @@ func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
slog.Error("repair pending changes failed", "error", err) slog.Error("repair pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pending changes repair failed",
}) })
_ = c.Error(err)
return return
} }
@@ -588,9 +597,7 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
users, err := h.syncService.ListUserSyncStatuses(threshold) users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
@@ -639,7 +646,8 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
slog.Error("failed to render sync_status template", "error", err) slog.Error("failed to render sync_status template", "error", err)
c.String(http.StatusInternalServerError, "Template error: "+err.Error()) _ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
} }
} }
@@ -675,7 +683,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -691,7 +699,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} }
if err := h.syncService.PushPartnumberSeen(items); err != nil { if err := h.syncService.PushPartnumberSeen(items); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
return return
} }

View File

@@ -62,7 +62,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -82,11 +82,11 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
spec := localdb.VendorSpec(body.VendorSpec) spec := localdb.VendorSpec(body.VendorSpec)
specJSON, err := json.Marshal(spec) specJSON, err := json.Marshal(spec)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil { if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -138,7 +138,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -147,14 +147,14 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
resolved, err := resolver.Resolve(body.VendorSpec) resolved, err := resolver.Resolve(body.VendorSpec)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
book, _ := bookRepo.GetActiveBook() book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -181,7 +181,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -196,12 +196,12 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
itemsJSON, err := json.Marshal(newItems) itemsJSON, err := json.Marshal(newItems)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil { if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"html/template" "html/template"
"strconv" "strconv"
"strings" "strings"
@@ -114,12 +115,14 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
tmpl, ok := h.templates[name] tmpl, ok := h.templates[name]
if !ok { if !ok {
c.String(500, "Template not found: %s", name) _ = c.Error(fmt.Errorf("template %q not found", name))
c.String(500, "Template error")
return return
} }
// Execute the page template which will use base // Execute the page template which will use base
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
c.String(500, "Template error: %v", err) _ = c.Error(err)
c.String(500, "Template error")
} }
} }

View File

@@ -0,0 +1,47 @@
package handlers
import (
"errors"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestWebHandlerRenderHidesTemplateExecutionError(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpl := template.Must(template.New("broken.html").Funcs(template.FuncMap{
"boom": func() (string, error) {
return "", errors.New("secret template failure")
},
}).Parse(`{{define "broken.html"}}{{boom}}{{end}}`))
handler := &WebHandler{
templates: map[string]*template.Template{
"broken.html": tmpl,
},
}
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodGet, "/broken", nil)
handler.render(ctx, "broken.html", gin.H{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
if body := strings.TrimSpace(rec.Body.String()); body != "Template error" {
t.Fatalf("expected generic template error, got %q", body)
}
if len(ctx.Errors) != 1 {
t.Fatalf("expected logged template error, got %d", len(ctx.Errors))
}
if !strings.Contains(ctx.Errors.String(), "secret template failure") {
t.Fatalf("expected original error in gin context, got %q", ctx.Errors.String())
}
}