Harden local runtime safety and error handling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
242
cmd/qfs/main.go
242
cmd/qfs/main.go
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
48
cmd/qfs/request_logger_test.go
Normal file
48
cmd/qfs/request_logger_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
internal/handlers/respond.go
Normal file
73
internal/handlers/respond.go
Normal 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
|
||||||
|
}
|
||||||
41
internal/handlers/respond_test.go
Normal file
41
internal/handlers/respond_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
internal/handlers/web_test.go
Normal file
47
internal/handlers/web_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user