From bd5737ad7aa9c4842bb7d46f199b305a2a1f8386 Mon Sep 17 00:00:00 2001
From: Tim Van Baak
Date: Wed, 12 Feb 2025 10:04:58 -0800
Subject: [PATCH] Web edit sources
---
README.md | 8 ++--
core/env.go | 34 ++++++++++++++---
web/html/editChannels.html | 2 +-
web/html/editSource.html | 54 ++++++++++++++++++++++++++
web/html/home.html | 3 ++
web/html/html.go | 14 +++++++
web/html/intake.css | 3 ++
web/main.go | 2 +
web/shlex.go | 44 ++++++++++++++++++++++
web/shlex_test.go | 60 +++++++++++++++++++++++++++++
web/source.go | 77 ++++++++++++++++++++++++++++++++++++++
11 files changed, 291 insertions(+), 10 deletions(-)
create mode 100644 web/html/editSource.html
create mode 100644 web/shlex.go
create mode 100644 web/shlex_test.go
diff --git a/README.md b/README.md
index b269420..ae8e2c4 100644
--- a/README.md
+++ b/README.md
@@ -110,11 +110,11 @@ Parity features
* [x] crontab integration
* [ ] source batching
* [x] add item from web
-* [ ] web edit channels
+* [x] web edit channels
* web edit sources
- * [ ] edit action argv
- * [ ] edit source envs
- * [ ] edit source crontab
+ * [x] edit action argv
+ * [x] edit source envs
+ * [x] edit source crontab
* [x] Nix build
* [ ] NixOS module
* [ ] NixOS vm demo
diff --git a/core/env.go b/core/env.go
index 7c5b94c..cb8337b 100644
--- a/core/env.go
+++ b/core/env.go
@@ -5,7 +5,7 @@ import (
"strings"
)
-func GetEnvs(db DB, source string) ([]string, error) {
+func GetEnvs(db DB, source string) (envs []string, err error) {
rows, err := db.Query(`
select name, value
from envs
@@ -15,19 +15,43 @@ func GetEnvs(db DB, source string) ([]string, error) {
return nil, err
}
defer rows.Close()
- var envs []string
for rows.Next() {
var name string
var value string
- if err := rows.Scan(&name, &value); err != nil {
+ if err = rows.Scan(&name, &value); err != nil {
return nil, err
}
envs = append(envs, fmt.Sprintf("%s=%s", name, value))
}
- if err := rows.Err(); err != nil {
+ if err = rows.Err(); err != nil {
return nil, err
}
- return envs, nil
+ return
+}
+
+func GetEnvsMap(db DB, source string) (envs map[string]string, err error) {
+ rows, err := db.Query(`
+ select name, value
+ from envs
+ where source = ?
+ `, source)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ envs = make(map[string]string)
+ for rows.Next() {
+ var name string
+ var value string
+ if err = rows.Scan(&name, &value); err != nil {
+ return nil, err
+ }
+ envs[name] = value
+ }
+ if err = rows.Err(); err != nil {
+ return nil, err
+ }
+ return
}
func SetEnvs(db DB, source string, envs []string) error {
diff --git a/web/html/editChannels.html b/web/html/editChannels.html
index b852c92..ea61926 100644
--- a/web/html/editChannels.html
+++ b/web/html/editChannels.html
@@ -19,7 +19,7 @@
diff --git a/web/html/editSource.html b/web/html/editSource.html
new file mode 100644
index 0000000..40531b3
--- /dev/null
+++ b/web/html/editSource.html
@@ -0,0 +1,54 @@
+{{ define "title" }}{{ .Name }} - Intake{{ end }}
+
+{{ define "content" -}}
+
+
+
+{{- end }}
diff --git a/web/html/home.html b/web/html/home.html
index 06f2c9c..0610f57 100644
--- a/web/html/home.html
+++ b/web/html/home.html
@@ -34,6 +34,9 @@
+
+(edit)
+ |
{{ .Name }} |
diff --git a/web/html/html.go b/web/html/html.go
index 6c0c1ff..f3c6705 100644
--- a/web/html/html.go
+++ b/web/html/html.go
@@ -182,3 +182,17 @@ func EditChannels(writer io.Writer, data EditChannelsData) {
log.Printf("error: failed to render edit channels: %v", err)
}
}
+
+var editSource = load("editSource.html")
+
+type EditSourceData struct {
+ Name string
+ Envs map[string]string
+ Actions map[string]string
+}
+
+func EditSource(writer io.Writer, data EditSourceData) {
+ if err := editSource.Execute(writer, data); err != nil {
+ log.Printf("error: failed to render edit source: %v", err)
+ }
+}
diff --git a/web/html/intake.css b/web/html/intake.css
index e8f951b..8c260ce 100644
--- a/web/html/intake.css
+++ b/web/html/intake.css
@@ -98,3 +98,6 @@ article textarea {
span.error-message {
color: red;
}
+#envvars input {
+ font-family: monospace;
+}
diff --git a/web/main.go b/web/main.go
index 82dd731..1c087f2 100644
--- a/web/main.go
+++ b/web/main.go
@@ -40,6 +40,8 @@ func RunServer(db core.DB, addr string, port string) {
handleFunc("GET /htmx.org@2.0.4.js", env.getScript, logged)
handleFunc("POST /login", env.login, logged)
handleFunc("GET /source/{source}", env.getSource, env.authed, logged)
+ handleFunc("GET /source/{source}/edit", env.getEditSource, env.authed, logged)
+ handleFunc("POST /source/{source}/edit", env.editSource, env.authed, logged)
handleFunc("POST /source/{source}/fetch", env.fetchSource, env.authed, logged)
handleFunc("GET /channel/", env.getChannels, env.authed, logged)
handleFunc("POST /channel/", env.editChannel, env.authed, logged)
diff --git a/web/shlex.go b/web/shlex.go
new file mode 100644
index 0000000..2d3fdb8
--- /dev/null
+++ b/web/shlex.go
@@ -0,0 +1,44 @@
+package web
+
+import (
+ "io"
+ "os/exec"
+ "strings"
+)
+
+func Shlex(cmd string) (argv []string, err error) {
+ xargs := exec.Command("xargs", "-n1")
+ stdin, err := xargs.StdinPipe()
+ if err != nil {
+ return
+ }
+ _, err = io.WriteString(stdin, cmd)
+ if err != nil {
+ return
+ }
+ err = stdin.Close()
+ if err != nil {
+ return
+ }
+ output, err := xargs.Output()
+ if err != nil {
+ return
+ }
+ argv = strings.Split(string(output), "\n")
+ if argv[len(argv)-1] == "" {
+ argv = argv[:len(argv)-1]
+ }
+ return
+}
+
+func Quote(argv []string) (cmd string, err error) {
+ argv = append([]string{"%q "}, argv...)
+ printf := exec.Command("printf", argv...)
+ output, err := printf.Output()
+ if err != nil {
+ return
+ }
+ cmd = string(output)
+ cmd = cmd[:len(cmd)-1] // strip off the last space from '%q '
+ return
+}
diff --git a/web/shlex_test.go b/web/shlex_test.go
new file mode 100644
index 0000000..93ca364
--- /dev/null
+++ b/web/shlex_test.go
@@ -0,0 +1,60 @@
+package web
+
+import "testing"
+
+func TestShlex(t *testing.T) {
+ shlex := func(t *testing.T, cmd string, expected ...string) {
+ t.Helper()
+ actual, err := Shlex(cmd)
+ if err != nil {
+ t.Fatalf("shlex failed: %v", err)
+ }
+ if len(actual) != len(expected) {
+ t.Fatalf("expected %d args, got %d\nexpect: %v\nactual: %v", len(expected), len(actual), expected, actual)
+ }
+ for i := range len(expected) {
+ if actual[i] != expected[i] {
+ t.Fatalf("arg %d incorrect: expected %s got %s", i, expected[i], actual[i])
+ }
+ }
+ }
+
+ quote := func(t *testing.T, argvAndExpected ...string) {
+ t.Helper()
+ argv := argvAndExpected[:len(argvAndExpected)-1]
+ expected := argvAndExpected[len(argvAndExpected)-1]
+ actual, err := Quote(argv)
+ if err != nil {
+ t.Fatalf("quote failed: %v", err)
+ }
+ if actual != expected {
+ t.Fatalf("expected %q, got %q", expected, actual)
+ }
+ }
+
+ shlex(t,
+ `Lord "have mercy"`,
+ "Lord", "have mercy",
+ )
+ shlex(t,
+ `jq -cR '{id: .}'`,
+ "jq", "-cR", `{id: .}`,
+ )
+ shlex(t,
+ `jq -cR '{id: "hello"}'`,
+ "jq", "-cR", `{id: "hello"}`,
+ )
+
+ quote(t,
+ "Lord", "have mercy",
+ `Lord 'have mercy'`,
+ )
+ quote(t,
+ "jq", "-cR", `{id: .}`,
+ `jq -cR '{id: .}'`,
+ )
+ quote(t,
+ "jq", "-cR", `{id: "hello"}`,
+ `jq -cR '{id: "hello"}'`,
+ )
+}
diff --git a/web/source.go b/web/source.go
index ec16f8e..c930594 100644
--- a/web/source.go
+++ b/web/source.go
@@ -78,3 +78,80 @@ func (env *Env) fetchSource(writer http.ResponseWriter, req *http.Request) {
}
html.Fetch(writer, data)
}
+
+func (env *Env) getEditSource(writer http.ResponseWriter, req *http.Request) {
+ source := req.PathValue("source")
+ if exists, err := core.SourceExists(env.db, source); !exists || err != nil {
+ http.NotFound(writer, req)
+ return
+ }
+
+ envs, err := core.GetEnvsMap(env.db, source)
+ if err != nil {
+ http.Error(writer, err.Error(), 500)
+ return
+ }
+
+ actions, _ := core.GetActionsForSource(env.db, source)
+ argvs := make(map[string]string)
+ for _, action := range actions {
+ argv, _ := core.GetArgvForAction(env.db, source, action)
+ args, _ := Quote(argv)
+ argvs[action] = args
+ }
+
+ data := html.EditSourceData{
+ Name: source,
+ Envs: envs,
+ Actions: argvs,
+ }
+ html.EditSource(writer, data)
+}
+
+func (env *Env) editSource(writer http.ResponseWriter, req *http.Request) {
+ source := req.PathValue("source")
+ if exists, err := core.SourceExists(env.db, source); !exists || err != nil {
+ http.NotFound(writer, req)
+ return
+ }
+
+ if err := req.ParseForm(); err != nil {
+ http.Error(writer, err.Error(), 400)
+ return
+ }
+
+ envName := req.PostForm.Get("envName")
+ envValue := req.PostForm.Get("envValue")
+ if envName != "" {
+ log.Printf("setting %s=%s", envName, envValue)
+ if err := core.SetEnvs(env.db, source, []string{fmt.Sprintf("%s=%s", envName, envValue)}); err != nil {
+ http.Error(writer, err.Error(), 500)
+ return
+ }
+ }
+
+ actionName := req.PostForm.Get("actionName")
+ actionArgv := req.PostForm.Get("actionArgv")
+ if actionName != "" {
+ log.Printf("setting %s -- %s", actionName, actionArgv)
+ if actionArgv == "" {
+ if err := core.DeleteAction(env.db, source, actionName); err != nil {
+ http.Error(writer, err.Error(), 500)
+ return
+ }
+ } else {
+ argv, err := Shlex(actionArgv)
+ if err != nil {
+ http.Error(writer, err.Error(), 400)
+ return
+ }
+ if err = core.SetAction(env.db, source, actionName, argv); err != nil {
+ http.Error(writer, err.Error(), 500)
+ return
+ }
+ }
+ }
+
+ writer.Header()["HX-Refresh"] = []string{"true"}
+ writer.WriteHeader(http.StatusNoContent)
+}