DodoApp: Public API to fetch allowed networks

Update landing page to communicate with dodo-app backend.

Change-Id: I269ad5150b9203eca9c1c9cc9a8a99b55c583419
diff --git a/apps/landing/hugo.toml b/apps/landing/hugo.toml
index 62892eb..b7cb8bc 100644
--- a/apps/landing/hugo.toml
+++ b/apps/landing/hugo.toml
@@ -1,3 +1,9 @@
 baseURL = 'https://example.org/'
 languageCode = 'en-us'
 title = 'Dodo'
+[[headers]]
+  for = '/**'
+  [headers.values]
+    Content-Security-Policy = 'connect-src app.v1.dodo.cloud'
+    Referrer-Policy = 'strict-origin-when-cross-origin'
+    X-Content-Type-Options = 'nosniff'
diff --git a/apps/landing/layouts/partials/register-form.html b/apps/landing/layouts/partials/register-form.html
index 1e1638c..3f35896 100644
--- a/apps/landing/layouts/partials/register-form.html
+++ b/apps/landing/layouts/partials/register-form.html
@@ -1,14 +1,19 @@
 <div class="form-container-footer">
-    <form method="POST" action="/register" class="form-group-footer">
+    <form id="register-form" method="POST" action="/register" class="form-group-footer" onsubmit="return register()">
 		<label>
 			domain
-			<select name="domain">
+			<select id="network" name="domain">
 				<option value="dodoapp.xyz">dodoapp.xyz</option>
 			</select>
 		</label>
 		<label>
 			subdomain
-			<input type="text" name="subdomain" />
+			<input id="subdomain" type="text" name="subdomain" />
+		</label>
+		<label>
+			application type
+			<select id="app-type" name="app-type">
+			</select>
 		</label>
         <label>
 			ssh public key
diff --git a/apps/landing/static/js/main.js b/apps/landing/static/js/main.js
index a068560..24e651e 100644
--- a/apps/landing/static/js/main.js
+++ b/apps/landing/static/js/main.js
@@ -139,3 +139,55 @@
         fact.addEventListener("mouseover", () => handleMouseover(facts[index].params.image, index, facts[index].params.title));
     });
 });
+
+async function loadPublicData() {
+  let networkSelect = document.querySelector("select#network");
+  if (networkSelect === undefined) {
+	return;
+  }
+  networkSelect.innerHTML = "";
+  let appTypeSelect = document.querySelector("select#app-type");
+  if (appTypeSelect === undefined) {
+	return;
+  }
+  appTypeSelect.innerHTML = "";
+  let resp = await fetch("https://app.v1.dodo.cloud/api/public-data");
+  if (!resp.ok) {
+	return;
+  }
+  let data = await resp.json();
+  data.networks.forEach((network) => {
+	let opt = document.createElement("option");
+	opt.setAttribute("value", network.domain);
+	opt.innerHTML = network.domain;
+	networkSelect.appendChild(opt);
+  });
+  data.types.forEach((t) => {
+	let opt = document.createElement("option");
+	opt.setAttribute("value", t);
+	opt.innerHTML = t;
+	appTypeSelect.appendChild(opt);
+  });
+}
+
+function register() {
+  var data = {
+	type: document.getElementById("app-type").value,
+	adminPublicKey: document.getElementById("public-key").value,
+	network: document.getElementById("network").value,
+	subdomain: document.getElementById("subdomain").value,
+  };
+  fetch("https://app.v1.dodo.cloud/api/apps", {
+	method: "POST",
+	body: JSON.stringify(data),
+  }).then((resp) => {
+	resp.json().then((r) => {
+	  console.log(r);
+	});
+  });
+  return false;
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+  loadPublicData();
+});
diff --git a/core/installer/welcome/app_tmpl.go b/core/installer/welcome/app_tmpl.go
index 252d431..dae56f8 100644
--- a/core/installer/welcome/app_tmpl.go
+++ b/core/installer/welcome/app_tmpl.go
@@ -14,6 +14,7 @@
 const tmplSuffix = ".gotmpl"
 
 type AppTmplStore interface {
+	Types() []string
 	Find(appType string) (AppTmpl, error)
 }
 
@@ -40,6 +41,14 @@
 	return &appTmplStoreFS{apps}, nil
 }
 
+func (s *appTmplStoreFS) Types() []string {
+	var ret []string
+	for t := range s.tmpls {
+		ret = append(ret, t)
+	}
+	return ret
+}
+
 func (s *appTmplStoreFS) Find(appType string) (AppTmpl, error) {
 	if app, ok := s.tmpls[appType]; ok {
 		return app, nil
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 4535be2..e6f3d37 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -37,6 +37,8 @@
 	loginPath      = "/login"
 	logoutPath     = "/logout"
 	staticPath     = "/static"
+	apiPublicData  = "/api/public-data"
+	apiCreateApp   = "/api/apps"
 	sessionCookie  = "dodo-app-session"
 	userCtx        = "user"
 )
@@ -90,7 +92,6 @@
 	jc                installer.JobCreator
 	workers           map[string]map[string]struct{}
 	appNs             map[string]string
-	sc                *securecookie.SecureCookie
 	tmplts            dodoAppTmplts
 	appTmpls          AppTmplStore
 }
@@ -117,10 +118,6 @@
 	if err != nil {
 		return nil, err
 	}
-	sc := securecookie.New(
-		securecookie.GenerateRandomKey(64),
-		securecookie.GenerateRandomKey(32),
-	)
 	apps, err := fs.Sub(appTmplsFS, "app-tmpl")
 	if err != nil {
 		return nil, err
@@ -148,7 +145,6 @@
 		jc,
 		map[string]map[string]struct{}{},
 		map[string]string{},
-		sc,
 		tmplts,
 		appTmpls,
 	}
@@ -175,6 +171,8 @@
 		r.Use(s.mwAuth)
 		r.PathPrefix(staticPath).Handler(http.FileServer(http.FS(staticResources)))
 		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
+		r.HandleFunc(apiPublicData, s.handleAPIPublicData)
+		r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
 		r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
 		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
 		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
@@ -184,10 +182,9 @@
 	}()
 	go func() {
 		r := mux.NewRouter()
-		r.HandleFunc("/update", s.handleApiUpdate)
-		r.HandleFunc("/api/apps/{app-name}/workers", s.handleApiRegisterWorker).Methods(http.MethodPost)
-		r.HandleFunc("/api/apps", s.handleApiCreateApp).Methods(http.MethodPost)
-		r.HandleFunc("/api/add-admin-key", s.handleApiAddAdminKey).Methods(http.MethodPost)
+		r.HandleFunc("/update", s.handleAPIUpdate)
+		r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-admin-key", s.handleAPIAddAdminKey).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
 	}()
 	return <-e
@@ -195,6 +192,7 @@
 
 type UserGetter interface {
 	Get(r *http.Request) string
+	Encode(w http.ResponseWriter, user string) error
 }
 
 type externalUserGetter struct {
@@ -202,7 +200,10 @@
 }
 
 func NewExternalUserGetter() UserGetter {
-	return &externalUserGetter{}
+	return &externalUserGetter{securecookie.New(
+		securecookie.GenerateRandomKey(64),
+		securecookie.GenerateRandomKey(32),
+	)}
 }
 
 func (ug *externalUserGetter) Get(r *http.Request) string {
@@ -217,6 +218,22 @@
 	return user
 }
 
+func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
+	if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
+		cookie := &http.Cookie{
+			Name:     sessionCookie,
+			Value:    encoded,
+			Path:     "/",
+			Secure:   true,
+			HttpOnly: true,
+		}
+		http.SetCookie(w, cookie)
+		return nil
+	} else {
+		return err
+	}
+}
+
 type internalUserGetter struct{}
 
 func NewInternalUserGetter() UserGetter {
@@ -227,9 +244,17 @@
 	return r.Header.Get("X-User")
 }
 
+func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
+	return nil
+}
+
 func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) || strings.HasPrefix(r.URL.Path, staticPath) {
+		if strings.HasSuffix(r.URL.Path, loginPath) ||
+			strings.HasPrefix(r.URL.Path, logoutPath) ||
+			strings.HasPrefix(r.URL.Path, staticPath) ||
+			strings.HasPrefix(r.URL.Path, apiPublicData) ||
+			strings.HasPrefix(r.URL.Path, apiCreateApp) {
 			next.ServeHTTP(w, r)
 			return
 		}
@@ -249,6 +274,7 @@
 }
 
 func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
+	// TODO(gio): move to UserGetter
 	http.SetCookie(w, &http.Cookie{
 		Name:     sessionCookie,
 		Value:    "",
@@ -309,15 +335,9 @@
 		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
 		return
 	}
-	if encoded, err := s.sc.Encode(sessionCookie, user); err == nil {
-		cookie := &http.Cookie{
-			Name:     sessionCookie,
-			Value:    encoded,
-			Path:     "/",
-			Secure:   true,
-			HttpOnly: true,
-		}
-		http.SetCookie(w, cookie)
+	if err := s.ug.Encode(w, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
 	}
 	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
 }
@@ -388,7 +408,7 @@
 	After string `json:"after"`
 }
 
-func (s *DodoAppServer) handleApiUpdate(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
 	fmt.Println("update")
 	var req apiUpdateReq
 	var contents strings.Builder
@@ -434,7 +454,7 @@
 	Address string `json:"address"`
 }
 
-func (s *DodoAppServer) handleApiRegisterWorker(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
 	vars := mux.Vars(r)
 	appName, ok := vars["app-name"]
 	if !ok || appName == "" {
@@ -525,7 +545,7 @@
 	Password string `json:"password"`
 }
 
-func (s *DodoAppServer) handleApiCreateApp(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
 	var req apiCreateAppReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
@@ -573,6 +593,7 @@
 		AppName:  appName,
 		Password: password,
 	}
+	w.Header().Set("Access-Control-Allow-Origin", "*")
 	if err := json.NewEncoder(w).Encode(resp); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -699,7 +720,7 @@
 	Public string `json:"public"`
 }
 
-func (s *DodoAppServer) handleApiAddAdminKey(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleAPIAddAdminKey(w http.ResponseWriter, r *http.Request) {
 	var req apiAddAdminKeyReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
@@ -783,6 +804,36 @@
 	return s.nf.Filter(user, networks)
 }
 
+type publicNetworkData struct {
+	Name   string `json:"name"`
+	Domain string `json:"domain"`
+}
+
+type publicData struct {
+	Networks []publicNetworkData `json:"networks"`
+	Types    []string            `json:"types"`
+}
+
+func (s *DodoAppServer) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
+	networks, err := s.getNetworks("")
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var ret publicData
+	for _, n := range networks {
+		ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
+	}
+	for _, t := range s.appTmpls.Types() {
+		ret.Types = append(ret.Types, strings.ReplaceAll(t, "-", ":"))
+	}
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	if err := json.NewEncoder(w).Encode(ret); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func pickNetwork(networks []installer.Network, network string) []installer.Network {
 	for _, n := range networks {
 		if n.Name == network {
@@ -802,7 +853,7 @@
 	return noNetworkFilter{}
 }
 
-func (f noNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
 	return networks, nil
 }
 
@@ -815,6 +866,9 @@
 }
 
 func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	if user == "" {
+		return networks, nil
+	}
 	network, err := f.st.GetUserNetwork(user)
 	if err != nil {
 		return nil, err
@@ -836,7 +890,7 @@
 	return &allowListFilter{allowed}
 }
 
-func (f *allowListFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
 	ret := []installer.Network{}
 	for _, n := range networks {
 		if slices.Contains(f.allowed, n.Name) {
@@ -854,11 +908,11 @@
 	return &combinedNetworkFilter{filters}
 }
 
-func (f *combinedNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
 	ret := networks
 	var err error
 	for _, f := range f.filters {
-		ret, err = f.Filter(app, ret)
+		ret, err = f.Filter(user, ret)
 		if err != nil {
 			return nil, err
 		}