package api import ( "encoding/json" "errors" "fmt" "net/http" "github.com/gin-gonic/gin" ) // errBadInput marks payload validation failures so import handlers can map // them to 400 while genuine DB failures stay 500. var errBadInput = errors.New("invalid import payload") // failImport maps replaceBoard errors: bad input -> 400, the rest -> 500. func failImport(c *gin.Context, err error) { if errors.Is(err, errBadInput) { fail(c, http.StatusBadRequest, err.Error()) return } fail(c, http.StatusInternalServerError, err.Error()) } // Export is the portable backup shape: version + settings + board tree. type Export struct { Version int `json:"version"` Settings map[string]json.RawMessage `json:"settings"` Sections []Section `json:"sections"` } func (s *Server) exportBoard(c *gin.Context) { settings, err := s.allSettings() if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } sections, err := s.board() if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections}) } // importBoard replaces board state inside one transaction. Ids from the // payload are preserved so a round-trip restores identical state. func (s *Server) importBoard(c *gin.Context) { var in Export if err := c.ShouldBindJSON(&in); err != nil || in.Sections == nil { fail(c, http.StatusBadRequest, "invalid export payload") return } if err := s.replaceBoard(in.Sections, in.Settings); err != nil { failImport(c, err) return } sections, err := s.board() if err != nil { fail(c, http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, sections) } // replaceBoard swaps all board rows inside one transaction — shared by the // Dash export import and the external-config importers. func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error { tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} { if _, err := tx.Exec(q); err != nil { return err } } for _, sec := range sections { if sec.ID == "" || sec.Name == "" { return fmt.Errorf("%w: section missing id or name", errBadInput) } if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`, sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil { return err } for _, it := range sec.Items { if it.ID == "" || it.Name == "" { return fmt.Errorf("%w: item missing id or name", errBadInput) } var cfg any if it.Config != nil { cfg = string(*it.Config) } if _, err := tx.Exec( `INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`, it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil { return err } for _, u := range it.URLs { if !validURL(u.URL) { return fmt.Errorf("%w: invalid url: %s", errBadInput, u.URL) } uid := u.ID if uid == "" { uid = newID("u") } if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`, uid, it.ID, u.URL, u.Label, u.Position); err != nil { return err } } } } for k, v := range settings { if _, err := tx.Exec( `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil { return err } } return tx.Commit() } func orDefault(v, def string) string { if v == "" { return def } return v }