All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
125 lines
3.5 KiB
Go
125 lines
3.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/lib/pq"
|
|
|
|
"switchboard-core/database"
|
|
)
|
|
|
|
// ── SafeJSON ────────────────────────────────
|
|
// SafeJSON pre-marshals the response body, guaranteeing that a
|
|
// serialisation failure produces a clean 500 instead of a truncated
|
|
// 200 with half-written JSON. Use for any endpoint whose response
|
|
// contains json.RawMessage or other pass-through data from the DB.
|
|
|
|
func SafeJSON(c *gin.Context, code int, obj interface{}) {
|
|
body, err := json.Marshal(obj)
|
|
if err != nil {
|
|
log.Printf("⚠ SafeJSON: marshal failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "response serialization failed",
|
|
})
|
|
return
|
|
}
|
|
c.Data(code, "application/json; charset=utf-8", body)
|
|
}
|
|
|
|
// ── JSON column scanner ─────────────────────
|
|
// Handles both Postgres ([]byte) and SQLite (string) return types.
|
|
// Validates content before accepting it — corrupt data is replaced
|
|
// with "{}" and a warning is logged so admins can find the bad row.
|
|
|
|
type jsonScanner struct{ dest *json.RawMessage }
|
|
|
|
func scanJSON(dest *json.RawMessage) *jsonScanner { return &jsonScanner{dest: dest} }
|
|
|
|
func (s *jsonScanner) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case []byte:
|
|
if len(v) == 0 || !json.Valid(v) {
|
|
log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.60q), defaulting to {}", len(v), v)
|
|
*s.dest = json.RawMessage("{}")
|
|
return nil
|
|
}
|
|
// CRITICAL: copy the driver buffer. json.RawMessage(v) aliases the
|
|
// underlying []byte owned by database/sql. The driver may reuse that
|
|
// memory on the next rows.Scan() call, corrupting our data after the
|
|
// fact. This caused intermittent SafeJSON 500s on paginated list
|
|
// endpoints where row N's Settings pointed to row N+1's overwritten
|
|
// buffer. Fixed.
|
|
cp := make([]byte, len(v))
|
|
copy(cp, v)
|
|
*s.dest = json.RawMessage(cp)
|
|
case string:
|
|
b := []byte(v)
|
|
if len(b) == 0 || !json.Valid(b) {
|
|
log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.60q), defaulting to {}", len(b), b)
|
|
*s.dest = json.RawMessage("{}")
|
|
return nil
|
|
}
|
|
*s.dest = json.RawMessage(b)
|
|
case nil:
|
|
*s.dest = json.RawMessage("{}")
|
|
default:
|
|
*s.dest = json.RawMessage("{}")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Tags column scanner ─────────────────────
|
|
// SQLite stores tags as a JSON text array; Postgres uses text[].
|
|
// Both paths validate and fall back to an empty slice on error.
|
|
|
|
type tagsScanner struct {
|
|
dest *[]string
|
|
}
|
|
|
|
func scanTags(dest *[]string) *tagsScanner {
|
|
return &tagsScanner{dest: dest}
|
|
}
|
|
|
|
func (s *tagsScanner) Scan(src interface{}) error {
|
|
if src == nil {
|
|
*s.dest = []string{}
|
|
return nil
|
|
}
|
|
|
|
if database.IsSQLite() {
|
|
var raw string
|
|
switch v := src.(type) {
|
|
case string:
|
|
raw = v
|
|
case []byte:
|
|
raw = string(v)
|
|
default:
|
|
*s.dest = []string{}
|
|
return nil
|
|
}
|
|
if raw == "" {
|
|
*s.dest = []string{}
|
|
return nil
|
|
}
|
|
var arr []string
|
|
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
|
log.Printf("⚠ scanTags: invalid JSON array in column (preview=%.60q), defaulting to []", raw)
|
|
*s.dest = []string{}
|
|
return nil
|
|
}
|
|
*s.dest = arr
|
|
return nil
|
|
}
|
|
|
|
// Postgres: delegate to pq, but catch errors
|
|
if err := pq.Array(s.dest).Scan(src); err != nil {
|
|
log.Printf("⚠ scanTags: pq.Array scan failed (%v), defaulting to []", err)
|
|
*s.dest = []string{}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|