diff --git a/backend/Dockerfile b/backend/Dockerfile index 68451a00..130c0806 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,7 +15,7 @@ COPY ext/ . # Download JARs for Saxon 12 (XSLT 3.0) RUN mkdir -p /tmp/saxon12 && \ curl -L -o /tmp/saxon12/saxon-he.jar \ - https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \ + https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.9/Saxon-HE-12.9.jar && \ curl -L -o /tmp/saxon12/gson.jar \ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar @@ -70,7 +70,7 @@ RUN chmod +x start.sh # Saxon 12 (XSLT 3.0) — port 8081 RUN mkdir -p /opt/saxon12 && \ curl -L -o /opt/saxon12/saxon-he.jar \ - https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \ + https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.9/Saxon-HE-12.9.jar && \ curl -L -o /opt/saxon12/xmlresolver.jar \ https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar && \ curl -L -o /opt/saxon12/gson.jar \ diff --git a/backend/src/fiddle.go b/backend/src/fiddle.go new file mode 100644 index 00000000..2f7abc1c --- /dev/null +++ b/backend/src/fiddle.go @@ -0,0 +1,203 @@ +package main + +import ( + "crypto/rand" + "errors" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Saved fiddles: a workspace payload stored under a short id, with an +// append-only revision history. Available whenever DATABASE_URL is set — +// unlike /history this does not require Pro mode or authentication, because a +// fiddle is by definition a thing you share with someone who has no account. + +type Fiddle struct { + ID string `json:"id" gorm:"primaryKey;size:12"` + Revision int `json:"revision" gorm:"primaryKey;autoIncrement:false"` + Payload string `json:"payload"` + CreatedAt time.Time `json:"created_at"` +} + +const ( + fiddleIDLen = 7 + fiddlePayloadMax = 200_000 // bytes; a workspace is KBs, this is sabotage headroom + fiddleMaxRevs = 200 // append-only cap per fiddle +) + +// Base58: no 0/O/I/l lookalikes, so ids survive being read aloud or retyped. +const fiddleAlphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +func newFiddleID() (string, error) { + buf := make([]byte, fiddleIDLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + id := make([]byte, fiddleIDLen) + for i, b := range buf { + id[i] = fiddleAlphabet[int(b)%len(fiddleAlphabet)] + } + return string(id), nil +} + +func validFiddleID(id string) bool { + if len(id) != fiddleIDLen { + return false + } + for _, c := range id { + ok := false + for _, a := range fiddleAlphabet { + if c == a { + ok = true + break + } + } + if !ok { + return false + } + } + return true +} + +func registerFiddleRoutes(r *gin.Engine, db *gorm.DB) { + if db == nil { + // Feature off (lightweight compose has no database): answer clearly + // instead of 404, so the frontend can hide the Save button. + off := func(c *gin.Context) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "fiddle storage is not configured"}) + } + r.POST("/fiddle", off) + r.GET("/fiddle/:id", off) + r.GET("/fiddle/:id/:rev", off) + return + } + + if err := db.AutoMigrate(&Fiddle{}); err != nil { + log.Fatalf("fiddle migrate: %v", err) + } + + r.POST("/fiddle", func(c *gin.Context) { + var req struct { + ID string `json:"id"` + Payload string `json:"payload"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Payload == "" || len(req.Payload) > fiddlePayloadMax { + c.JSON(http.StatusBadRequest, gin.H{"error": "payload missing or too large"}) + return + } + + revision := 1 + id := req.ID + if id != "" { + // New revision of an existing fiddle. + if !validFiddleID(id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fiddle id"}) + return + } + var last Fiddle + err := db.Where("id = ?", id).Order("revision desc").First(&last).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "fiddle not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if last.Revision >= fiddleMaxRevs { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "revision limit reached"}) + return + } + revision = last.Revision + 1 + } else { + // Fresh fiddle: collisions are astronomically unlikely (58^7) but + // retrying costs nothing. + for attempt := 0; ; attempt++ { + candidate, err := newFiddleID() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "id generation failed"}) + return + } + var count int64 + db.Model(&Fiddle{}).Where("id = ?", candidate).Count(&count) + if count == 0 { + id = candidate + break + } + if attempt >= 4 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "id space exhausted"}) + return + } + } + } + + rec := Fiddle{ID: id, Revision: revision, Payload: req.Payload, CreatedAt: time.Now()} + if err := db.Create(&rec).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + c.JSON(http.StatusOK, gin.H{"id": id, "revision": revision}) + }) + + load := func(c *gin.Context, id string, revision int) { + if !validFiddleID(id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fiddle id"}) + return + } + q := db.Where("id = ?", id) + if revision > 0 { + q = q.Where("revision = ?", revision) + } + var rec Fiddle + if err := q.Order("revision desc").First(&rec).Error; errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "fiddle not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + var total int64 + db.Model(&Fiddle{}).Where("id = ?", id).Count(&total) + c.JSON(http.StatusOK, gin.H{ + "id": rec.ID, + "revision": rec.Revision, + "revisions": total, + "payload": rec.Payload, + }) + } + + r.GET("/fiddle/:id", func(c *gin.Context) { load(c, c.Param("id"), 0) }) + r.GET("/fiddle/:id/:rev", func(c *gin.Context) { + rev := 0 + if _, err := parseRev(c.Param("rev"), &rev); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid revision"}) + return + } + load(c, c.Param("id"), rev) + }) +} + +func parseRev(s string, out *int) (int, error) { + n := 0 + if s == "" { + return 0, errors.New("empty") + } + for _, c := range s { + if c < '0' || c > '9' { + return 0, errors.New("not a number") + } + n = n*10 + int(c-'0') + if n > fiddleMaxRevs { + return 0, errors.New("out of range") + } + } + *out = n + return n, nil +} diff --git a/backend/src/fiddle_test.go b/backend/src/fiddle_test.go new file mode 100644 index 00000000..d7070c18 --- /dev/null +++ b/backend/src/fiddle_test.go @@ -0,0 +1,44 @@ +package main + +import "testing" + +func TestNewFiddleIDShapeAndUniqueness(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 500; i++ { + id, err := newFiddleID() + if err != nil { + t.Fatalf("id generation failed: %v", err) + } + if !validFiddleID(id) { + t.Fatalf("generated id %q does not validate", id) + } + if seen[id] { + t.Fatalf("duplicate id in 500 draws: %q", id) + } + seen[id] = true + } +} + +func TestValidFiddleID(t *testing.T) { + for _, bad := range []string{"", "short", "toolongid", "abc-def", "abc0def", "abcOdef", "abcIdef", "abcldef", "../../x"} { + if validFiddleID(bad) { + t.Errorf("expected %q to be rejected", bad) + } + } + id, _ := newFiddleID() + if !validFiddleID(id) { + t.Errorf("expected generated id %q to validate", id) + } +} + +func TestParseRev(t *testing.T) { + var n int + if _, err := parseRev("7", &n); err != nil || n != 7 { + t.Fatalf("parseRev(7) = %d, %v", n, err) + } + for _, bad := range []string{"", "abc", "-1", "1e3", "9999"} { + if _, err := parseRev(bad, &n); err == nil { + t.Errorf("expected %q to be rejected", bad) + } + } +} diff --git a/backend/src/main.go b/backend/src/main.go index 69a55179..c95d0af4 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -46,6 +46,7 @@ type TransformResponse struct { Result string `json:"result"` DurationMs int64 `json:"duration_ms"` Trace []TraceEntry `json:"trace,omitempty"` + TraceEngine string `json:"trace_engine,omitempty"` Hotspots []Hotspot `json:"hotspots,omitempty"` TraceText string `json:"trace_text,omitempty"` SecondaryResults map[string]string `json:"secondary_results,omitempty"` @@ -334,6 +335,21 @@ func main() { db *gorm.DB ) + // The database used to be a Pro-only concern; saved fiddles need it too, + // and unlike /history they are unauthenticated. Open it whenever a URL is + // configured, and keep Firebase/history strictly behind goPro. + if config.DatabaseURL != "" && os.Getenv("DISABLE_DATABASE") != "true" { + var err error + db, err = gorm.Open(postgres.Open(config.DatabaseURL), &gorm.Config{}) + if err != nil { + // Not fatal outside Pro mode: the chart has shipped a placeholder + // databaseUrl for a long time, so an unreachable database must + // degrade to "fiddles off", not crashloop the whole backend. + log.Printf("db connect failed, fiddle storage disabled: %v", err) + db = nil + } + } + if goPro { ctx := context.Background() var fbOpt option.ClientOption @@ -349,9 +365,8 @@ func main() { log.Fatalf("auth client: %v", err) } - db, err = gorm.Open(postgres.Open(config.DatabaseURL), &gorm.Config{}) - if err != nil { - log.Fatalf("db connect: %v", err) + if db == nil { + log.Fatalf("pro mode requires DATABASE_URL") } if err := db.AutoMigrate(&Transformation{}); err != nil { log.Fatalf("auto migrate: %v", err) @@ -368,6 +383,8 @@ func main() { r.Use(metricsMiddleware()) r.Use(corsMiddleware()) + registerFiddleRoutes(r, db) + r.POST("/transform", func(c *gin.Context) { var req TransformRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -428,11 +445,27 @@ func main() { } daemonPort := "8081" // Saxon 12 — XSLT 3.0 (default) + traceEngine := "" switch req.Version { case "1.0": daemonPort = "8082" // XSLTC (JDK) — true XSLT 1.0 + if req.Trace { + // XSLTC has no TraceListener hook; be explicit instead of + // silently returning an empty trace (which is what happened + // for every non-3.0 version until now). + traceEngine = "unavailable" + } case "2.0": daemonPort = "8083" // Saxon 9 — true XSLT 2.0 + if req.Trace { + // The trace instrumentation lives in Runner, which needs + // Saxon 10+ APIs and cannot load inside the Saxon 9 daemon. + // Rather than returning an empty trace, run the traced request + // on Saxon 12 in 2.0 backwards-compatible mode: a real trace + // with a small, documented semantic difference. + daemonPort = "8081" + traceEngine = "saxon12-compat" + } } start := time.Now() @@ -544,6 +577,7 @@ func main() { Result: daemonResp.Result, DurationMs: duration, Trace: traceEntries, + TraceEngine: traceEngine, Hotspots: hotspots, TraceText: traceText, SecondaryResults: daemonResp.SecondaryResults, diff --git a/frontend/index.html b/frontend/index.html index bd7bb891..f7bbfa69 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -59,7 +59,7 @@ }, "featureList": [ "XSLT 1.0, 2.0 and 3.0 support", - "Saxon HE 12.5 processor", + "Three real engines: XSLTC (1.0), Saxon 9 (2.0), Saxon HE 12.9 (3.0)", "Real-time XML transformation", "XSLT validator with detailed error messages", "Multiple XML inputs and parameters", @@ -89,7 +89,7 @@ "name": "Does this XSLT tester support XSLT 3.0?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.5. You can use maps, arrays, xsl:merge, xsl:on-empty, higher-order functions, and all other XSLT 3.0 features." + "text": "Yes. XSLT Playground is one of the very few free online tools that fully supports XSLT 3.0 via Saxon HE 12.9. You can use maps, arrays, xsl:merge, xsl:on-empty, higher-order functions, and all other XSLT 3.0 features." } }, { @@ -97,7 +97,7 @@ "name": "What XSLT processor does XSLT Playground use?", "acceptedAnswer": { "@type": "Answer", - "text": "The backend uses Saxon HE 12.5, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, used in enterprise applications worldwide." + "text": "The backend uses Saxon HE 12.9, the open-source edition of Saxonica's Saxon processor — the industry standard for XSLT 2.0 and 3.0 processing, used in enterprise applications worldwide." } }, { @@ -332,7 +332,7 @@

XSLT Playground is the most powerful free online XSLT tool available — an editor, tester, viewer and validator in one. Unlike every other online XSLT tester, it supports - XSLT 1.0, XSLT 2.0, and XSLT 3.0 via Saxon HE 12.5 — the same + XSLT 1.0, XSLT 2.0, and XSLT 3.0 via Saxon HE 12.9 — the same enterprise-grade processor used in production applications worldwide. Run any XSLT transformation online and see the result instantly. No installation, no account, no limits.

@@ -385,7 +385,7 @@