DodoApp: Use JSON file for configuration.

Specify json schema so code editors can validate user input.
Update auth proxy to disable auth on specified paths.

Change-Id: Ic6667d802a9553444d3630c4ff73f4b33304ccfd
diff --git a/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl b/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl
deleted file mode 100755
index 9cd1442..0000000
--- a/core/installer/welcome/app-tmpl/golang-1.20.0/app.cue.gotmpl
+++ /dev/null
@@ -1,9 +0,0 @@
-app: {
-	type: "golang:1.20.0"
-	run: "main.go"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
-	}
-}
diff --git a/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl b/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl
new file mode 100755
index 0000000..60356c6
--- /dev/null
+++ b/core/installer/welcome/app-tmpl/golang-1.20.0/app.json.gotmpl
@@ -0,0 +1,14 @@
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "golang:1.20.0",
+		"run": "main.go",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl b/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl
deleted file mode 100644
index cbf7b23..0000000
--- a/core/installer/welcome/app-tmpl/hugo-latest/app.cue.gotmpl
+++ /dev/null
@@ -1,8 +0,0 @@
-app: {
-	type: "hugo:latest"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
-	}
-}
diff --git a/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl b/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl
new file mode 100644
index 0000000..bc71973
--- /dev/null
+++ b/core/installer/welcome/app-tmpl/hugo-latest/app.json.gotmpl
@@ -0,0 +1,13 @@
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "hugo:latest",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
+	}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl b/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
index fa925fe..f5a061d 100755
--- a/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
+++ b/core/installer/welcome/app-tmpl/php-8.2-apache/app.cue.gotmpl
@@ -1,8 +1,13 @@
-app: {
-	type: "php:8.2-apache"
-	ingress: {
-		network: "{{ .Network.Name }}"
-		subdomain: "{{ .Subdomain }}"
-		auth: enabled: false
+{
+	"$schema": "{{ .SchemaAddr }}",
+	"app": {
+		"type": "php:8.2-apache",
+		"ingress": {
+			"network": "{{ .Network.Name }}",
+			"subdomain": "{{ .Subdomain }}",
+			"auth": {
+				"enabled": false
+			}
+		}
 	}
-}
+}
\ No newline at end of file
diff --git a/core/installer/welcome/app_tmpl.go b/core/installer/welcome/app_tmpl.go
index fb512fa..911b9b8 100644
--- a/core/installer/welcome/app_tmpl.go
+++ b/core/installer/welcome/app_tmpl.go
@@ -68,7 +68,7 @@
 }
 
 type AppTmpl interface {
-	Render(network installer.Network, subdomain string) (map[string][]byte, error)
+	Render(schemaAddr string, network installer.Network, subdomain string) (map[string][]byte, error)
 }
 
 type appTmplFS struct {
@@ -108,7 +108,7 @@
 	return &appTmplFS{files, tmpls}, nil
 }
 
-func (a *appTmplFS) Render(network installer.Network, subdomain string) (map[string][]byte, error) {
+func (a *appTmplFS) Render(schemaAddr string, network installer.Network, subdomain string) (map[string][]byte, error) {
 	ret := map[string][]byte{}
 	for path, contents := range a.files {
 		ret[path] = contents
@@ -116,8 +116,9 @@
 	for path, tmpl := range a.tmpls {
 		var buf bytes.Buffer
 		if err := tmpl.Execute(&buf, map[string]any{
-			"Network":   network,
-			"Subdomain": subdomain,
+			"SchemaAddr": schemaAddr,
+			"Network":    network,
+			"Subdomain":  subdomain,
 		}); err != nil {
 			return nil, err
 		}
diff --git a/core/installer/welcome/app_tmpl_test.go b/core/installer/welcome/app_tmpl_test.go
index 00e5a3c..f3ff71e 100644
--- a/core/installer/welcome/app_tmpl_test.go
+++ b/core/installer/welcome/app_tmpl_test.go
@@ -35,7 +35,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -53,7 +53,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -71,7 +71,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if _, err := a.Render(network, "testapp"); err != nil {
+	if _, err := a.Render("schema.json", network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 4307866..d71bf83 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -35,12 +35,16 @@
 //go:embed all:app-tmpl
 var appTmplsFS embed.FS
 
+//go:embed stat/schemas/app.schema.json
+var dodoAppJsonSchema []byte
+
 const (
 	ConfigRepoName = "config"
 	appConfigsFile = "/apps.json"
 	loginPath      = "/login"
 	logoutPath     = "/logout"
 	staticPath     = "/stat/"
+	schemasPath    = "/schemas/"
 	apiPublicData  = "/api/public-data"
 	apiCreateApp   = "/api/apps"
 	sessionCookie  = "dodo-app-session"
@@ -94,6 +98,7 @@
 	port              int
 	apiPort           int
 	self              string
+	selfPublic        string
 	repoPublicAddr    string
 	sshKey            string
 	gitRepoPublicKey  string
@@ -128,6 +133,7 @@
 	port int,
 	apiPort int,
 	self string,
+	selfPublic string,
 	repoPublicAddr string,
 	sshKey string,
 	gitRepoPublicKey string,
@@ -163,6 +169,7 @@
 		port,
 		apiPort,
 		self,
+		selfPublic,
 		repoPublicAddr,
 		sshKey,
 		gitRepoPublicKey,
@@ -218,6 +225,7 @@
 	go func() {
 		r := mux.NewRouter()
 		r.Use(s.mwAuth)
+		r.HandleFunc(schemasPath+"app.schema.json", s.handleSchema).Methods(http.MethodGet)
 		r.PathPrefix(staticPath).Handler(cachingHandler{http.FileServer(http.FS(statAssets))})
 		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
 		r.HandleFunc(apiPublicData, s.handleAPIPublicData)
@@ -320,6 +328,7 @@
 		if strings.HasSuffix(r.URL.Path, loginPath) ||
 			strings.HasPrefix(r.URL.Path, logoutPath) ||
 			strings.HasPrefix(r.URL.Path, staticPath) ||
+			strings.HasPrefix(r.URL.Path, schemasPath) ||
 			strings.HasPrefix(r.URL.Path, apiPublicData) ||
 			strings.HasPrefix(r.URL.Path, apiCreateApp) {
 			next.ServeHTTP(w, r)
@@ -340,6 +349,11 @@
 	})
 }
 
+func (s *DodoAppServer) handleSchema(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/schema+json")
+	w.Write(dodoAppJsonSchema)
+}
+
 func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
 	// TODO(gio): move to UserGetter
 	http.SetCookie(w, &http.Cookie{
@@ -1017,7 +1031,7 @@
 	if err != nil {
 		return err
 	}
-	appCfg, err := soft.ReadFile(appRepo, "app.cue")
+	appCfg, err := soft.ReadFile(appRepo, "app.json")
 	if err != nil {
 		return err
 	}
@@ -1025,7 +1039,7 @@
 	if err != nil {
 		return err
 	}
-	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.cue": branchCfg})
+	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.json": branchCfg})
 }
 
 func (s *DodoAppServer) createAppForBranch(
@@ -1238,7 +1252,7 @@
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
-	appCfg, err := soft.ReadFile(repo, "app.cue")
+	appCfg, err := soft.ReadFile(repo, "app.json")
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
@@ -1316,7 +1330,7 @@
 	if err != nil {
 		return nil, err
 	}
-	return appTmpl.Render(network, subdomain)
+	return appTmpl.Render(fmt.Sprintf("%s/stat/schemas/dodo_app.jsonschema", s.selfPublic), network, subdomain)
 }
 
 func generatePassword() string {
@@ -1677,7 +1691,9 @@
 }
 
 func createDevBranchAppConfig(from []byte, branch, username string) (string, []byte, error) {
-	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{"app.cue": from})
+	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{
+		"app.cue": from,
+	})
 	if err != nil {
 		return "", nil, err
 	}
diff --git a/core/installer/welcome/dodo_app_test.go b/core/installer/welcome/dodo_app_test.go
index 0f0f526..8fc83e7 100644
--- a/core/installer/welcome/dodo_app_test.go
+++ b/core/installer/welcome/dodo_app_test.go
@@ -5,14 +5,17 @@
 )
 
 func TestCreateDevBranch(t *testing.T) {
-	cfg := []byte(`
-app: {
-	type: "golang:1.22.0"
-	run: "main.go"
-	ingress: {
-		network: "private"
-		subdomain: "testapp"
-		auth: enabled: false
+	cfg := []byte(`{
+	"app": {
+		"type": "golang:1.22.0",
+		"run": "main.go",
+		"ingress": {
+			"network": "private",
+			"subdomain": "testapp",
+			"auth": {
+				"enabled": false
+			}
+		}
 	}
 }`)
 	network, newCfg, err := createDevBranchAppConfig(cfg, "foo", "bar")
diff --git a/core/installer/welcome/stat/schemas/app.schema.json b/core/installer/welcome/stat/schemas/app.schema.json
new file mode 100644
index 0000000..f6264de
--- /dev/null
+++ b/core/installer/welcome/stat/schemas/app.schema.json
@@ -0,0 +1,210 @@
+{
+  "$id": "https://dodo.cloud/schemas/app.schema.json",
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "app": {
+      "type": "object",
+      "oneOf": [
+        {
+          "$ref": "#/definitions/golang"
+        },
+        {
+          "$ref": "#/definitions/hugo"
+        },
+        {
+          "$ref": "#/definitions/php"
+        }
+      ]
+    }
+  },
+  "definitions": {
+    "golang": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "golang:1.22.0"
+            },
+            {
+              "const": "golang:1.20.0"
+            }
+          ]
+        },
+        "run": {
+          "type": "string"
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        },
+        "volumes": {
+          "$ref": "#/definitions/volumes"
+        },
+        "postgresql": {
+          "$ref": "#/definitions/postgresql"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "hugo": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "hugo:latest"
+            }
+          ]
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "php": {
+      "type": "object",
+      "properties": {
+        "type": {
+          "type": "string",
+          "oneOf": [
+            {
+              "const": "php:8.2-apache"
+            }
+          ]
+        },
+        "ingress": {
+          "$ref": "#/definitions/ingress"
+        },
+        "volumes": {
+          "$ref": "#/definitions/volumes"
+        },
+        "postgresql": {
+          "$ref": "#/definitions/postgresql"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "type"
+      ]
+    },
+    "volume": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "size": {
+          "$ref": "#/definitions/size"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "size"
+      ]
+    },
+    "volumes": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/volume"
+      }
+    },
+    "postgre": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "size": {
+          "$ref": "#/definitions/size"
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "size"
+      ]
+    },
+    "postgresql": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/postgre"
+      }
+    },
+    "size": {
+      "type": "string",
+      "pattern": "[1-9][0-9]*(Mi|Gi)"
+    },
+    "ingress": {
+      "type": "object",
+      "properties": {
+        "network": {
+          "type": "string",
+          "minLength": 1
+        },
+        "subdomain": {
+          "type": "string",
+          "minLength": 1
+        },
+        "auth": {
+          "type": "object",
+          "oneOf": [
+            {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "enum": [
+                    false
+                  ]
+                }
+              },
+              "additionalProperties": false,
+              "required": [
+                "enabled"
+              ]
+            },
+            {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "enum": [
+                    true
+                  ]
+                },
+                "groups": {
+                  "type": "string"
+                },
+                "noAuthPathPrefixes": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+					"pattern": "^/.*"
+                  }
+                }
+              },
+              "additionalProperties": false,
+              "required": [
+                "enabled"
+              ]
+            }
+          ]
+        }
+      },
+      "additionalProperties": false,
+      "required": [
+        "network",
+        "subdomain"
+      ]
+    }
+  }
+}