wip/refact-lib (#36)

Co-authored-by: Evan Chen <evanchen@kumoly.io>
Co-authored-by: Evan <evanchen333@gmail.com>
Reviewed-on: #36
Co-authored-by: evanchen <evanchen@kumoly.io>
Co-committed-by: evanchen <evanchen@kumoly.io>
pull/39/head
evanchen 2021-10-23 04:56:24 +00:00
parent a2c16da520
commit db1f9f3ee8
29 changed files with 818 additions and 486 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
dist
node_modules
.parcel-cache
public/css/main.css*
public/css/main.css.map
public/js/main.js*

View File

@ -4,13 +4,20 @@
* download files #26
* add goto last line toolbar #21
* use parcel to load main.js
* use icons
* bind ctrl-s (cmd-s) to fileSave
* refactor as lib #15
* Set Title to be changeable #24
* show run result below #33
* set font size #31
* Hide Config zone for other usage such as go playground #23
## Fix
* all cmds should be pass straight back to user
* timeout on none ending cmds #34
* all cmd output should be pass straight back to user
* timeout on none ending cmd #34
* add sys setting to conf #32
* Apply should save before taking action #19
# 0.1.1

View File

@ -11,7 +11,7 @@ COPY . .
RUN VERSION=$(git describe --tags --abbrev=0) BUILD=$(git rev-parse --short HEAD) && \
GOOS=linux GOARCH=amd64 \
go build -ldflags "-X main.Version=${VERSION} -X main.Build=${BUILD} -w" \
-o /go/bin/configui
-o /go/bin/configui cmd/configui/main.go
FROM alpine:3.14

View File

@ -20,29 +20,33 @@ clean:
run: build
$(shell cd dist; ./${PROJ} -log configui.log)
.PHONY: web
web:
npm run build
# npm run js-dev
.PHONY: build
build:
npm run build
go build ${LDFLAGS} -o dist/${PROJ}
go build ${LDFLAGS} -o dist/${PROJ} cmd/$(PROJ)/main.go
build-unix:
$(foreach GOOS, $(PLATFORMS), $(foreach GOARCH, $(ARCHITECTURES), $(foreach APP, $(APPS),\
$(shell export GOOS=$(GOOS); export GOARCH=$(GOARCH); go build ${LDFLAGS} -o dist/$(APP)) \
$(shell export GOOS=$(GOOS); export GOARCH=$(GOARCH); go build ${LDFLAGS} -o dist/$(APP) cmd/$(APP)/main.go) \
$(shell cd dist; tar -czf ${APP}_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz ${APP}) \
$(shell rm dist/${APP}) \
)))
build-win:
$(foreach APP, $(APPS), \
$(shell export GOOS=windows; export GOARCH=amd64; go build ${LDFLAGS} -o dist/${APP}.exe) \
$(shell export GOOS=windows; export GOARCH=amd64; go build ${LDFLAGS} -o dist/${APP}.exe cmd/$(APP)/main.go) \
$(shell cd dist; tar -czf ${APP}_$(VERSION)_windows_amd64.tar.gz ${APP}.exe) \
$(shell rm dist/${APP}.exe) \
)
build-mac-m1:
$(foreach APP, $(APPS),\
$(shell export GOOS=darwin; export GOARCH=arm64; go build ${LDFLAGS} -o dist/$(APP)) \
$(shell export GOOS=darwin; export GOARCH=arm64; go build ${LDFLAGS} -o dist/$(APP) cmd/$(APP)/main.go) \
$(shell cd dist; tar -czf ${APP}_$(VERSION)_darwin_arm64.tar.gz ${APP}) \
$(shell rm dist/${APP}) \
)
@ -67,4 +71,4 @@ docker-save:
.PHONY: release
release: clean binary docker docker-save
release: clean web binary docker docker-save

View File

@ -15,7 +15,7 @@ Usage: configui [options]
-log string
log to file
-n string
alias of file
Name of file
-p string
path to file, precedence over config
-static
@ -84,7 +84,7 @@ res:
### File
`GET /api/file?name=ALIAS`
`GET /api/file?name=Name`
res:
```json
@ -110,4 +110,4 @@ req:
### Apply
`POST /api/apply?name=ALIAS`
`POST /api/apply?name=Name`

155
api.go
View File

@ -1,155 +0,0 @@
package main
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"kumoly.io/tools/configui/configui"
)
func ListFiles(w http.ResponseWriter, r *http.Request) {
data, err := json.Marshal(files)
if err != nil {
panic(err)
}
w.Write(data)
}
func GetFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, ok := files[name]
if name == "" || !ok {
MakeResponse(w, 404, []byte("file not found"))
return
}
data, err := file.Read()
if err != nil {
panic(err)
}
response, err := json.Marshal(map[string]string{
"path": file.Path,
"name": file.Alias,
"action": file.Action,
"data": string(data),
})
if err != nil {
panic(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
func PostFile(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body)
r.Body.Close()
if err != nil {
panic(err)
}
f := configui.File{}
if err := json.Unmarshal(data, &f); err != nil {
panic(err)
}
file, ok := files[f.Alias]
if !ok {
MakeResponse(w, 404, []byte("file not found"))
return
}
if err := file.Write([]byte(f.Data)); err != nil {
panic(err)
}
w.Write([]byte("ok"))
}
func Apply(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, ok := files[name]
if name == "" || !ok {
MakeResponse(w, 404, []byte("file not found"))
return
}
result, err := file.Do()
log.Println(err)
if err != nil {
panic(err)
}
w.Write([]byte(result))
}
func Download(w http.ResponseWriter, r *http.Request) {
if name := r.URL.Query().Get("name"); name != "" {
if name == "ConfigUI" {
data, err := GetConfig()
if err != nil {
panic(err)
}
w.Header().Set("Content-Disposition", `attachment; filename="ConfigUI.json"`)
w.Write(data)
return
}
file, ok := files[name]
if !ok {
MakeResponse(w, 404, []byte("file not found"))
return
}
data, err := file.Read()
if err != nil {
panic(err)
}
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(file.Path)+`"`)
w.Write(data)
return
}
fs := []string{}
for _, v := range files {
fs = append(fs, v.Path)
}
if flagConfigPath != "" {
fs = append(fs, flagConfigPath)
}
w.Header().Set("Content-Disposition", `attachment; filename="export.tar.gz"`)
bundle(w, fs, false)
}
func LoadConfig(w http.ResponseWriter, r *http.Request) {
if flagNoReconfig {
panic("system reconfig is disabled")
}
data, err := ioutil.ReadAll(r.Body)
r.Body.Close()
if err != nil {
panic(err)
}
ftmp, err := configui.ReadConfig(string(data))
if err != nil {
panic(err)
}
if flagConfigPath != "" {
info, err := os.Stat(flagConfigPath)
if err != nil {
panic(err)
}
os.WriteFile(flagConfigPath, data, info.Mode())
}
files = configui.GetFileMap(ftmp)
w.Write([]byte("ok"))
}
func getConfigHandler(w http.ResponseWriter, r *http.Request) {
data, err := GetConfig()
if err != nil {
panic(err)
}
w.Write(data)
}
func GetConfig() ([]byte, error) {
config := []configui.File{}
for _, f := range files {
config = append(config, *f)
}
return json.MarshalIndent(config, "", " ")
}

112
app.go Normal file
View File

@ -0,0 +1,112 @@
package configui
import (
"net/http"
"path/filepath"
"runtime"
"sort"
"strings"
)
type ActiveFile struct {
RO bool
Path string
Name string
Action string
Content string
Order int
}
type Editor struct {
Lang string
Platform string
}
type Page struct {
AppName string
Files []ActiveFile
Error string
File ActiveFile
Editor Editor
Static bool
HideConfig bool
ResultBellow bool
}
func (cui *ConfigUI) App(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
Files := []ActiveFile{}
for _, i := range cui.fileIndex {
Files = append(Files, ActiveFile{
Name: cui.Files[i].Name,
Path: cui.Files[i].Path,
Order: cui.Files[i].Order,
})
}
sort.Slice(Files, func(i, j int) bool {
if Files[i].Order == Files[j].Order {
return Files[i].Name < Files[j].Name
}
return Files[i].Order < Files[j].Order
})
plat := "unix"
if runtime.GOOS == "windows" {
plat = "windows"
}
data := Page{
AppName: cui.AppName,
File: ActiveFile{},
Files: Files,
Editor: Editor{
Platform: plat,
},
Static: cui.NoReconfig,
HideConfig: cui.HideConfig,
ResultBellow: cui.ResultBellow,
}
content := ""
var tmp []byte
var err error
name := r.URL.Query().Get("name")
file, err := cui.File(name)
if name == "" || err != nil {
tmp, err = cui.Config()
data.File.Name = cui.AppName
data.File.Path = ":mem:"
data.Editor.Lang = "json"
if name != "" {
data.Error = name + " not found\n"
}
} else {
tmp, err = file.Read()
data.File.Action = file.Action
data.File.Name = file.Name
if file.Lang != "" {
data.Editor.Lang = file.Lang
} else {
ext := strings.TrimPrefix(filepath.Ext(file.Path), ".")
if Ext2Mode[ext] != "" {
ext = Ext2Mode[ext]
}
data.Editor.Lang = ext
}
data.File.RO = file.RO
data.File.Path = file.Path
}
if err != nil {
data.Error = err.Error()
data.Editor.Lang = ""
} else {
content = string(tmp)
}
data.File.Content = content
cui.Parse(w, "home", data)
}

View File

@ -1,28 +1,21 @@
package main
import (
"embed"
"flag"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"time"
"kumoly.io/tools/configui/configui"
"kumoly.io/tools/configui/public"
"kumoly.io/tools/configui"
)
//go:embed templates
var tmplFS embed.FS
var tmpl *template.Template
var (
flagPath string
flagAction string
flagAlias string
flagName string
flagConfigPath string
flagBind string
@ -34,14 +27,13 @@ var (
var Version = "0.0.0"
var Build = "alpha"
var files = map[string]*configui.File{}
func init() {
// log.SetFlags(0)
flag.StringVar(&flagConfigPath, "f", "", "path to config file")
flag.StringVar(&flagPath, "p", "", "path to file, precedence over config")
flag.StringVar(&flagAlias, "n", "", "alias of file")
flag.StringVar(&flagName, "n", "", "Name of file")
flag.StringVar(&flagAction, "c", "", "cmd to apply")
flag.StringVar(&flagLogFile, "log", "", "log to file")
flag.StringVar(&flagAllow, "allow", "", "IPs to allow, blank to allow all")
@ -62,6 +54,8 @@ func main() {
return
}
cui := configui.New()
// setup logging
if flagLogFile != "" {
f, err := os.OpenFile(flagLogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
@ -75,78 +69,40 @@ func main() {
// setup values
if flagPath != "" {
if flagAlias == "" {
flagAlias = flagPath
if flagName == "" {
flagName = flagPath
}
files[flagAlias] = &configui.File{
file := &configui.File{
Path: flagPath,
Alias: flagAlias,
Name: flagName,
Action: flagAction,
}
if err := cui.AppendFile(file); err != nil {
log.Fatalln(err)
}
} else if flagConfigPath == "" {
log.Println("no config found")
log.Println("no config specified")
} else {
conf, err := os.ReadFile(flagConfigPath)
if err != nil {
log.Fatalln(err)
}
ftmp, err := configui.ReadConfig(string(conf))
cui.LoadConfig(string(conf))
if err != nil {
log.Fatalln(err)
}
files = configui.GetFileMap(ftmp)
}
cui.LogPath = flagLogFile
cui.ConfigPath = flagConfigPath
cui.NoReconfig = flagNoReconfig
// setup routes
mux := http.NewServeMux()
mux.HandleFunc("/api/conf", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
getConfigHandler(w, r)
} else if r.Method == "POST" {
LoadConfig(w, r)
} else {
w.WriteHeader(404)
}
})
mux.HandleFunc("/api/files", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
ListFiles(w, r)
} else {
w.WriteHeader(404)
}
})
mux.HandleFunc("/api/file", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
GetFile(w, r)
} else if r.Method == "POST" {
PostFile(w, r)
} else {
w.WriteHeader(404)
}
})
mux.HandleFunc("/api/apply", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
Apply(w, r)
} else {
w.WriteHeader(404)
}
})
mux.HandleFunc("/api/export", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
Download(w, r)
} else {
w.WriteHeader(404)
}
})
mux.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.FS(public.FS))))
tmpl = template.Must(template.New("").ParseFS(tmplFS, "templates/*.tmpl", "templates/**/*.tmpl"))
setRoutes(mux)
server := &http.Server{
Addr: flagBind,
WriteTimeout: time.Second * 3,
WriteTimeout: time.Second * 30,
ReadTimeout: time.Second * 30,
Handler: Middleware(mux),
Handler: cui,
}
// start server

132
configui.go Normal file
View File

@ -0,0 +1,132 @@
package configui
import (
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"kumoly.io/tools/configui/public"
)
var UNIX_SHELL = "sh"
var WIN_SHELL = "cmd"
//go:embed templates
var tmplFS embed.FS
var Ext2Mode map[string]string = map[string]string{
"go": "golang",
"log": "sh",
"txt": "text",
"yml": "yaml",
"conf": "ini",
}
type ConfigUI struct {
AppName string `json:"app_name"`
ConfigPath string `json:"config_path"`
NoReconfig bool `json:"no_reconfig"`
AllowIP string `json:"allow_ip"`
Files []*File `json:"files"`
fileIndex map[string]int
// TODO
ResultBellow bool `json:"result_bellow"`
HideConfig bool `json:"hide_config"`
// Should be in main app
LogPath string `json:"log_path"`
SilentSysOut bool `json:"silent_sys_out"`
TmplFS embed.FS `json:"-"`
tmpl *template.Template
PublicFS embed.FS `json:"-"`
}
func New() *ConfigUI {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
"step": func(from, to, step uint) []uint {
items := []uint{}
for i := from; i <= to; i += step {
items = append(items, i)
}
return items
},
}).ParseFS(tmplFS, "templates/*.tmpl", "templates/**/*.tmpl"))
return &ConfigUI{
fileIndex: map[string]int{},
AppName: "ConfigUI",
PublicFS: public.FS,
TmplFS: tmplFS,
tmpl: tmpl,
}
}
func (cui *ConfigUI) File(file_name string) (*File, error) {
if file_name == cui.AppName {
return &File{
Path: cui.ConfigPath,
Name: cui.AppName,
Lang: "json",
}, nil
}
index, ok := cui.fileIndex[file_name]
if !ok {
return nil, errors.New("no file found")
}
return cui.Files[index], nil
}
func (cui *ConfigUI) LoadConfig(confstr string) error {
tmpConf := &ConfigUI{}
err := json.Unmarshal([]byte(confstr), tmpConf)
if err != nil {
return nil
}
// construct fileIndex
tmpIndex := map[string]int{}
for i, f := range tmpConf.Files {
if f.Name == "" {
f.Name = f.Path
}
tmpIndex[f.Name] = i
}
// copy
cui.fileIndex = tmpIndex
cui.Files = tmpConf.Files
cui.AllowIP = tmpConf.AllowIP
cui.ConfigPath = tmpConf.ConfigPath
cui.HideConfig = tmpConf.HideConfig
cui.LogPath = tmpConf.LogPath
cui.NoReconfig = tmpConf.NoReconfig
cui.AppName = tmpConf.AppName
// NOT implemented
cui.ResultBellow = tmpConf.ResultBellow
cui.SilentSysOut = tmpConf.SilentSysOut
// fmt.Printf("%+v", cui)
return nil
}
func (cui *ConfigUI) Config() ([]byte, error) {
return json.MarshalIndent(cui, "", " ")
}
func (cui *ConfigUI) AppendFile(file *File) error {
if file.Name == "" {
file.Name = file.Path
}
i, ok := cui.fileIndex[file.Name]
if ok {
return fmt.Errorf("%v already exists at %d", file.Name, i)
}
cui.fileIndex[file.Name] = len(cui.Files)
cui.Files = append(cui.Files, file)
return nil
}

View File

@ -11,16 +11,17 @@ import (
"time"
)
var UNIX_SHELL = "sh"
var WIN_SHELL = "cmd"
type File struct {
Path string `json:"path"`
Alias string `json:"name"`
Name string `json:"name"`
Action string `json:"action"`
// RO is readonly
RO bool `json:"ro"`
Lang string `json:"lang"`
// Order order of the display on ui
Order int `json:"order"`
// used for parsing post data
Data string `json:"data"`
@ -55,29 +56,32 @@ func (f *File) Do() (string, error) {
if f.Action == "" {
return "", nil
}
timeout := time.After(2 * time.Second)
f.lock.RLock()
defer f.lock.RUnlock()
cmd := &exec.Cmd{}
if runtime.GOOS == "windows" {
cmd = exec.Command(WIN_SHELL, "/c", f.Action)
} else {
exec.Command(UNIX_SHELL, "-c", f.Action)
cmd = exec.Command(UNIX_SHELL, "-c", f.Action)
}
var out []byte
done := make(chan struct{}, 1)
log.Println("DO: ", f.Action)
done := make(chan string, 1)
go func() {
out, _ = cmd.CombinedOutput()
// real cmd err is only pass down
out, _ := cmd.CombinedOutput()
// real cmd err is unhandled, but passed to client
// if err != nil {
// return string(out), err
// }
done <- struct{}{}
done <- string(out)
}()
select {
case <-timeout:
case <-time.After(10 * time.Second):
cmd.Process.Kill()
log.Println("timeout")
return "", errors.New("command timeout")
case <-done:
return string(out), nil
case out := <-done:
log.Printf("\n%v", out)
return out, nil
}
}
@ -88,8 +92,8 @@ func ReadConfig(confstr string) ([]File, error) {
return nil, err
}
for i := range conf {
if conf[i].Alias == "" {
conf[i].Alias = conf[i].Path
if conf[i].Name == "" {
conf[i].Name = conf[i].Path
}
}
return conf, nil
@ -98,7 +102,7 @@ func ReadConfig(confstr string) ([]File, error) {
func GetFileMap(files []File) map[string]*File {
fileMap := map[string]*File{}
for i := range files {
fileMap[files[i].Alias] = &files[i]
fileMap[files[i].Name] = &files[i]
}
return fileMap
}

148
handler.go Normal file
View File

@ -0,0 +1,148 @@
package configui
import (
"encoding/json"
"io/ioutil"
"net/http"
"os"
"path/filepath"
)
func (cui *ConfigUI) ListFiles(w http.ResponseWriter, r *http.Request) {
data, err := json.Marshal(cui.Files)
if err != nil {
panic(err)
}
w.Write(data)
}
func (cui *ConfigUI) GetFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
return
}
data, err := file.Read()
if err != nil {
panic(err)
}
response, err := json.Marshal(map[string]string{
"path": file.Path,
"name": file.Name,
"action": file.Action,
"data": string(data),
})
if err != nil {
panic(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
func (cui *ConfigUI) PostFile(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body)
r.Body.Close()
if err != nil {
panic(err)
}
f := File{}
if err := json.Unmarshal(data, &f); err != nil {
panic(err)
}
file, err := cui.File(f.Name)
if err != nil {
response(w, 404, []byte("file not found"))
return
}
if err := file.Write([]byte(f.Data)); err != nil {
panic(err)
}
w.Write([]byte("ok"))
}
func (cui *ConfigUI) Apply(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
return
}
result, err := file.Do()
if err != nil {
panic(err)
}
w.Write([]byte(result))
}
func (cui *ConfigUI) Download(w http.ResponseWriter, r *http.Request) {
if name := r.URL.Query().Get("name"); name != "" {
if name == cui.AppName {
data, err := cui.Config()
if err != nil {
panic(err)
}
w.Header().Set(
"Content-Disposition", `
attachment; filename="`+cui.AppName+`.json"`,
)
w.Write(data)
return
}
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
return
}
data, err := file.Read()
if err != nil {
panic(err)
}
w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(file.Path)+`"`)
w.Write(data)
return
}
fs := []string{}
for _, i := range cui.fileIndex {
fs = append(fs, cui.Files[i].Path)
}
if cui.ConfigPath != "" {
fs = append(fs, cui.ConfigPath)
}
w.Header().Set("Content-Disposition", `attachment; filename="export.tar.gz"`)
bundle(w, fs, cui.AppName, false)
}
func (cui *ConfigUI) PostConfig(w http.ResponseWriter, r *http.Request) {
if cui.NoReconfig {
panic("system reconfig is disabled")
}
data, err := ioutil.ReadAll(r.Body)
r.Body.Close()
if err != nil {
panic(err)
}
err = cui.LoadConfig(string(data))
if err != nil {
panic(err)
}
if cui.ConfigPath != "" {
info, err := os.Stat(cui.ConfigPath)
if err != nil {
panic(err)
}
err = os.WriteFile(cui.ConfigPath, data, info.Mode())
if err != nil {
panic(err)
}
}
w.Write([]byte("ok"))
}
func (cui *ConfigUI) GetConfig(w http.ResponseWriter, r *http.Request) {
data, err := cui.Config()
if err != nil {
panic(err)
}
w.Write(data)
}

View File

@ -1,20 +1,18 @@
package main
package configui
import (
"bytes"
"log"
"net"
"net/http"
"strconv"
"strings"
)
type ResponseWriter struct {
type CuiResponseWriter struct {
http.ResponseWriter
StatueCode int
}
func (w *ResponseWriter) WriteHeader(statusCode int) {
func (w *CuiResponseWriter) WriteHeader(statusCode int) {
if w.StatueCode != 0 {
return
}
@ -22,19 +20,19 @@ func (w *ResponseWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *ResponseWriter) Write(body []byte) (int, error) {
func (w *CuiResponseWriter) Write(body []byte) (int, error) {
if w.StatueCode == 0 {
w.WriteHeader(200)
}
return w.ResponseWriter.Write(body)
}
func MakeResponse(w http.ResponseWriter, status int, body []byte) (int, error) {
func response(w http.ResponseWriter, status int, body []byte) (int, error) {
w.WriteHeader(status)
return w.Write(body)
}
func AbortError(w http.ResponseWriter, err interface{}) (int, error) {
func abort(w http.ResponseWriter, err interface{}) (int, error) {
switch v := err.(type) {
case int:
w.WriteHeader(v)
@ -51,56 +49,17 @@ func AbortError(w http.ResponseWriter, err interface{}) (int, error) {
}
}
func Catch(rw *ResponseWriter, r *http.Request) {
func catch(rw *CuiResponseWriter, r *http.Request) {
ex := recover()
if ex != nil {
AbortError(rw, ex)
abort(rw, ex)
log.Printf("%s %s %d %s %s\n", GetIP(r), r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
rw := &ResponseWriter{w, 0}
defer Catch(rw, r)
abort := false
if flagAllow != "" {
if !matchIPGlob(GetIP(r), flagAllow) {
MakeResponse(rw, 403, []byte("permission denyed"))
abort = true
}
}
if !abort {
next.ServeHTTP(rw, r)
}
if !strings.HasPrefix(r.URL.Path, "/public") && r.URL.Query().Get("f") != "true" {
log.Printf("%s %s %d %s %s\n", GetIP(r), r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
},
)
}
func GetIP(r *http.Request) string {
ip := r.Header.Get("X-Real-Ip")
if ip == "" {
ips := r.Header.Get("X-Forwarded-For")
ipArr := strings.Split(ips, ",")
ip = strings.Trim(ipArr[len(ipArr)-1], " ")
}
if ip == "" {
var err error
ip, _, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
}
return ip
}
func Parse(w http.ResponseWriter, name string, data interface{}) error {
func (cui *ConfigUI) Parse(w http.ResponseWriter, name string, data interface{}) error {
buf := &bytes.Buffer{}
err := tmpl.ExecuteTemplate(buf, "home", data)
err := cui.tmpl.ExecuteTemplate(buf, "home", data)
if err != nil {
panic(err)
}

File diff suppressed because one or more lines are too long

View File

1
public/css/main.css Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,5 +2,5 @@ package public
import "embed"
//go:embed js css ace assets
//go:embed css ace assets
var FS embed.FS

101
route.go
View File

@ -1,101 +0,0 @@
package main
import (
"net/http"
"path/filepath"
"runtime"
"sort"
"strings"
)
type OnPageFile struct {
RO bool
Path string
Alias string
Action string
Content string
}
type Editor struct {
Lang string
Platform string
}
type Page struct {
Files []OnPageFile
Error string
File OnPageFile
Editor Editor
Static bool
}
func setRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
Files := []OnPageFile{}
for file := range files {
Files = append(Files, OnPageFile{
Alias: files[file].Alias,
Path: files[file].Path,
})
}
sort.Slice(Files, func(i, j int) bool { return Files[i].Alias < Files[j].Alias })
plat := "unix"
if runtime.GOOS == "windows" {
plat = "windows"
}
data := Page{
File: OnPageFile{},
Files: Files,
Editor: Editor{
Platform: plat,
},
Static: flagNoReconfig,
}
content := ""
var tmp []byte
var err error
name := r.URL.Query().Get("name")
file, ok := files[name]
if name == "" || !ok {
tmp, err = GetConfig()
data.File.Alias = "ConfigUI"
data.File.Path = ":mem:"
data.Editor.Lang = "json"
if name != "" {
data.Error = name + " not found\n"
}
} else {
tmp, err = file.Read()
data.File.Action = file.Action
data.File.Alias = file.Alias
if file.Lang != "" {
data.Editor.Lang = file.Lang
} else {
ext := strings.TrimPrefix(filepath.Ext(file.Path), ".")
if ext2mode[ext] != "" {
ext = ext2mode[ext]
}
data.Editor.Lang = ext
}
data.File.RO = file.RO
data.File.Path = file.Path
}
if err != nil {
data.Error = err.Error()
data.Editor.Lang = ""
} else {
content = string(tmp)
}
data.File.Content = content
Parse(w, "home", data)
})
}

78
server.go Normal file
View File

@ -0,0 +1,78 @@
package configui
import (
"log"
"net/http"
"strings"
)
func (cui *ConfigUI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cui.middleware(cui.mux()).ServeHTTP(w, r)
}
func (cui *ConfigUI) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
rw := &CuiResponseWriter{w, 0}
defer catch(rw, r)
ip := GetIP(r)
if cui.AllowIP != "" {
if !matchIPGlob(ip, cui.AllowIP) {
rw.WriteHeader(403)
panic("permission denied")
}
}
next.ServeHTTP(rw, r)
//logging
if !strings.HasPrefix(r.URL.Path, "/public") && r.URL.Query().Get("f") != "true" {
log.Printf("%s %s %d %s %s\n", ip, r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
},
)
}
func (cui *ConfigUI) mux() *http.ServeMux {
cuiR := http.NewServeMux()
cuiR.HandleFunc("/api/conf", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.GetConfig(w, r)
} else if r.Method == "POST" {
cui.PostConfig(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/files", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.ListFiles(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/file", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.GetFile(w, r)
} else if r.Method == "POST" {
cui.PostFile(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/apply", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
cui.Apply(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/export", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.Download(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.FS(cui.PublicFS))))
cuiR.HandleFunc("/", cui.App)
return cuiR
}

View File

@ -2,31 +2,33 @@
window.ToolIsFollow = false;
async function FileGet(follower=false){
FileGet= async function(follower=false){
let f = ''
if (Active == 'ConfigUI') {
if (follower) f = '?f=true'
const res = await fetch('/api/conf'+f);
const res = await fetch('/api/conf'+f)
.catch(err=>{console.log(err);return;});
const body = await res.text();
if(!res.ok){
handleError(res)
Catch(res)
return
}
editor.session.setValue(body);
}
else {
if (follower) f = '&f=true'
const res = await fetch('/api/file?name=' + Active + f);
const res = await fetch('/api/file?name=' + Active + f)
.catch(err=>{console.log(err);return;});
const body = await res.json();
if(!res.ok){
handleError(res)
Catch(res)
return
}
editor.session.setValue(body.data);
}
}
async function FileSave(){
FileSave = async function(){
if (Active == 'ConfigUI') {
const res = await fetch('/api/conf', {
method: 'POST',
@ -34,9 +36,9 @@ async function FileSave(){
headers: new Headers({
'Content-Type': 'application/json'
})
})
}).catch(err=>{console.log(err);return;});
if (res.ok) window.location.reload();
else handleError(res)
else Catch(res)
}
else {
const res = await fetch('/api/file', {
@ -45,21 +47,21 @@ async function FileSave(){
headers: new Headers({
'Content-Type': 'application/json'
})
})
if(!res.ok) handleError(res)
}).catch(err=>{console.log(err);return;});
if(!res.ok) Catch(res)
}
}
async function FileApply(){
FileApply = async function(){
if (Active == 'ConfigUI') {
return;
}
else {
const res = await fetch('/api/apply?name='+ Active, {
method: 'POST',
})
}).catch(err=>{console.log(err);return;});
if(!res.ok){
const result = await handleError(res)
const result = await Catch(res)
result_editor.session.setValue(result)
return
}
@ -68,7 +70,9 @@ async function FileApply(){
}
}
async function handleError(res){
Catch=async function(res){
console.trace()
console.log(res)
const msg = await res.text()
ShowError(msg)
return msg
@ -89,4 +93,12 @@ async function handleError(res){
editor.gotoLine(editor.session.getLength());
}
}), 1000)
// block ctl-s
window.addEventListener("keypress", function(event) {
if (!(event.which == 115 && event.ctrlKey) && !(event.which == 19)) return true
alert("Ctrl-S pressed")
event.preventDefault()
return false
})
}())

View File

@ -22,7 +22,12 @@ $modal-content-width: 90vw;
}
.icn-spinner {
animation: spin-animation 0.5s infinite;
animation: spin-animation 1s infinite;
display: inline-block;
}
.icn-loading {
animation: spin-animation 3s infinite;
display: inline-block;
}

View File

@ -1,7 +1,8 @@
{{define "base/footer"}}
<script src="/public/ace/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script src="/public/ace/js/ext-beautify.js" type="text/javascript" charset="utf-8"></script>
<script src="/public/js/main.js"></script>
{{template "base/script" .}}
<!-- <script src="/public/js/main.js"></script> -->
</body>
</html>
{{end}}

116
templates/base/script.tmpl Normal file
View File

@ -0,0 +1,116 @@
{{define "base/script"}}
<script>
window.ToolIsFollow = false;
window.ContentChanged = false;
async function FileGet(follower=false){
let f = ''
if (Active == 'ConfigUI') {
if (follower) f = '?f=true'
const res = await fetch('/api/conf'+f)
.catch(err=>{console.log(err);return;});
const body = await res.text();
if(!res.ok){
Catch(res)
return
}
editor.session.setValue(body);
}
else {
if (follower) f = '&f=true'
const res = await fetch('/api/file?name=' + Active + f)
.catch(err=>{console.log(err);return;});
const body = await res.json();
if(!res.ok){
Catch(res)
return
}
editor.session.setValue(body.data);
}
}
async function FileSave(){
if (Active == '{{.AppName}}') {
const res = await fetch('/api/conf', {
method: 'POST',
body: editor.getValue(),
headers: new Headers({
'Content-Type': 'application/json'
})
}).catch(err=>{console.log(err);return;});
if (res.ok) {
window.location.reload();
window.ContentChanged = false
}
else Catch(res)
}
else {
const res = await fetch('/api/file', {
method: 'POST',
body: JSON.stringify({"name":Active,"data":editor.getValue()}),
headers: new Headers({
'Content-Type': 'application/json'
})
}).catch(err=>{console.log(err);return;});
if(!res.ok) Catch(res)
else window.ContentChanged = false
}
}
async function FileApply(){
if (Active == '{{.AppName}}') {
return;
}
else {
const res = await fetch('/api/apply?name='+ Active, {
method: 'POST',
});
if(!res.ok){
const result = await Catch(res)
result_editor.session.setValue(result)
return
}
const result = await res.text()
result_editor.session.setValue(result)
}
}
async function Catch(res){
{{/* console.trace()
console.log(res) */}}
const msg = await res.text()
ShowError(msg)
return msg
}
function setResult(){
var result_editor = ace.edit("result_editor");
window.result_editor = result_editor
result_editor.setTheme("ace/theme/monokai");
result_editor.session.setMode("ace/mode/sh");
result_editor.setReadOnly(true);
result_editor.setOptions({
maxLines: 1000
});
}
// starting point
(function(){
// setup ace editor
setEditor()
// setup result code block
setResult()
// for follow mode
setInterval((async ()=>{
if (ToolIsFollow){
await FileGet(true)
editor.gotoLine(editor.session.getLength());
}
}), 1000)
}())
</script>
{{end}}

View File

@ -15,6 +15,21 @@ function setEditor(){
editor.session.setUseWrapMode(false);
editor.session.setNewLineMode('{{.Editor.Platform}}')
editor.setReadOnly({{.File.RO}});
editor.session.on('change', function(delta) {
// delta.start, delta.end, delta.lines, delta.action
window.ContentChanged = true
});
editor.commands.addCommand({
name: 'SaveContent',
bindKey: {win: 'Ctrl-S', mac: 'Command-S'},
exec: function(editor) {
//...
FileSave()
},
readOnly: false // false if this command should not apply in readOnly mode
});
}
</script>
{{end}}

View File

@ -7,23 +7,25 @@
<ul class="menu-list">
{{ range .Files }}
<li>
<a class="has-tooltip-arrow {{if eq $.File.Alias .Alias }}is-active{{end}}"
<a class="has-tooltip-arrow {{if eq $.File.Name .Name }}is-active{{end}}"
data-tooltip="{{.Path}}"
href="/?name={{.Alias}}"
>{{ .Alias }}</a>
href="/?name={{.Name}}"
>{{ .Name }}</a>
</li>
{{ end }}
</ul>
{{if not .HideConfig}}
<p class="menu-label">
ConfigUI
System
</p>
<ul class="menu-list">
<li>
<a class="has-tooltip-arrow {{if eq .File.Alias "ConfigUI" }}is-active{{end}}"
<a class="has-tooltip-arrow {{if eq .File.Name .AppName }}is-active{{end}}"
data-tooltip="{{$.File.Path}}"
href="/"
>config.json</a></li>
>Config</a></li>
</ul>
{{end}}
</aside>
</section>
{{end}}

View File

@ -3,7 +3,7 @@
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{.File.Alias}}</p>
<p class="modal-card-title">{{.File.Name}}</p>
<button class="delete" aria-label="close" onclick="ResultViewTog()"></button>
</header>
<section class="modal-card-body">
@ -23,17 +23,6 @@
</div>
<script>
function setResult(){
var result_editor = ace.edit("result_editor");
window.result_editor = result_editor
result_editor.setTheme("ace/theme/monokai");
result_editor.session.setMode("ace/mode/sh");
result_editor.setReadOnly(true);
result_editor.setOptions({
maxLines: 1000
});
}
function ResultViewTog(){
let el = document.getElementById('result_view')
el.classList.toggle('is-active')

View File

@ -11,21 +11,11 @@
</span></span>
</button>
</p>
<p class="control">
<div class="select is-small">
<select onchange="SetTabstop(this)">
<option value="0">Tabstop</option>
<option value="4">Tabstop 4</option>
<option value="2">Tabstop 2</option>
<option value="-1">Tab</option>
</select>
</div>
</p>
<p class="control">
<button class="button is-small has-tooltip-arrow" id="toolFollow"
data-tooltip="Follow file change (1s)" onclick="toolFollow()">
<span class="icon is-small"><span class="material-icons" id="toolFollowIcon">
pause
sync_disabled
</span></span>
</button>
</p>
@ -47,32 +37,54 @@
</span></span>
</button>
</p>
<p class="control">
<div class="select is-small">
<select onchange="SetTabstop(this)">
<option value="0">Tabstop</option>
<option value="4">Tabstop 4</option>
<option value="2">Tabstop 2</option>
<option value="-1">Tab</option>
</select>
</div>
</p>
<p class="control">
<div class="select is-small">
<select onchange="toolSetFontSize(this)">
<option value="1rem">1rem</option>
{{range step 8 36 2}}
<option value="{{.}}px">{{.}}px</option>
{{end}}
</select>
</div>
</p>
</div>
</div>
<div class="level-right">
<div class="field has-addons">
<p class="control">
<button class="button is-small has-tooltip-arrow" id="toolRefresh"
<button class="button is-small has-tooltip-arrow"
data-tooltip="Refresh" onclick="toolRefresh()">
<span class="icon is-small"><span class="material-icons">
<span class="icon is-small"><span class="material-icons" id="toolRefreshIco">
refresh
</span></span>
</button>
</p>
{{if not .File.RO}}
<p class="control">
{{if eq .File.Alias "ConfigUI"}}
<button class="button is-small has-tooltip-arrow" id="toolSave"
{{if eq .File.Name .AppName}}
{{if not .Static}}
<button class="button is-small has-tooltip-arrow"
data-tooltip="Apply" onclick="toolSave()">
<span class="icon is-small"><span class="material-icons">
<span class="icon is-small"><span class="material-icons" id="toolSaveIco">
play_arrow
</span></span>
</button>
{{end}}
{{else}}
<button class="button is-small has-tooltip-arrow" id="toolSave"
<button class="button is-small has-tooltip-arrow"
data-tooltip="Save" onclick="toolSave()">
<span class="icon is-small"><span class="material-icons">
<span class="icon is-small"><span class="material-icons" id="toolSaveIco">
save
</span></span>
</button>
@ -83,18 +95,17 @@
<p class="control">
<button class="button is-small has-tooltip-arrow"
data-tooltip="{{.File.Action}}"
id="toolApply" onclick="toolApply()">
<span class="icon is-small"><span class="material-icons">
onclick="toolApply()">
<span class="icon is-small"><span class="material-icons"id="toolApplyIco">
play_arrow
</span></span>
</button>
</p>
{{end}}
{{/* TEST */}}
<p class="control">
<a class="button is-small has-tooltip-arrow" data-tooltip="Download"
href="/api/export?name={{.File.Alias}}">
href="/api/export?name={{.File.Name}}">
<span class="icon is-small"><span class="material-icons">
download
</span></span>
@ -145,6 +156,12 @@ function toolFormat(){
beautify.beautify(editor.session);
}
function toolSetFontSize(obj){
let value = obj.value;
document.getElementById('editor').style.fontSize=value;
document.getElementById('result_editor').style.fontSize=value;
}
function toolFollow(){
if (ToolIsFollow){
ToolIsFollow = false
@ -152,7 +169,7 @@ function toolFollow(){
let icon = document.getElementById('toolFollowIcon');
el.classList.remove('is-primary');
icon.classList.remove('icn-spinner');
icon.innerText='pause';
icon.innerText='sync_disabled';
} else {
ToolIsFollow = true
let el = document.getElementById('toolFollow');
@ -165,25 +182,34 @@ function toolFollow(){
}
async function toolRefresh(){
let el = document.getElementById('toolRefresh');
el.classList.add("is-loading")
let el = document.getElementById('toolRefreshIco');
el.classList.toggle('icn-loading')
await FileGet()
el.classList.remove("is-loading")
el.classList.toggle('icn-loading')
}
async function toolSave(){
let el = document.getElementById('toolSave');
el.classList.add("is-loading")
let el = document.getElementById('toolSaveIco');
let originText = el.innerText;
el.innerText='refreash';
el.classList.add("icn-loading")
await FileSave()
el.classList.remove("is-loading")
el.innerText=originText;
el.classList.remove("icn-loading")
}
async function toolApply(){
let el = document.getElementById('toolApply');
el.classList.add("is-loading")
let el = document.getElementById('toolApplyIco');
let originText = el.innerText;
el.innerText='rotate_right';
el.classList.add("icn-loading")
if (window.ContentChanged){
await FileSave()
}
await FileApply()
el.classList.remove("is-loading")
ResultViewTog()
el.innerText=originText;
el.classList.remove("icn-loading")
{{if not .ResultBellow}}ResultViewTog(){{end}}
}
</script>
{{end}}

View File

@ -2,13 +2,13 @@
{{template "base/header" .}}
{{template "components/error" .}}
<script>
var Active = "{{.File.Alias}}";
var Active = "{{.File.Name}}";
</script>
<section class="hero is-small is-primary">
<div class="hero-body" id="title">
<p class="title">
ConfigUI
{{.AppName}}
</p>
</div>
</section>
@ -24,11 +24,21 @@ var Active = "{{.File.Alias}}";
</div>
<div class="column">
<div class="box">
{{if and .HideConfig (eq .File.Name .AppName)}}
Home
{{else}}
{{template "components/editor" .}}
{{end}}
</div>
{{if .ResultBellow}}
<div class="box">
<div id="result_editor"></div>
</div>
{{end}}
</div>
</div>
{{if not .ResultBellow}}
{{template "components/result" .}}
{{end}}
{{template "base/footer" .}}
{{end}}

31
util.go
View File

@ -1,9 +1,11 @@
package main
package configui
import (
"archive/tar"
"compress/gzip"
"io"
"net"
"net/http"
"os"
"strings"
)
@ -51,7 +53,7 @@ func matchIPGlob(ip, pattern string) bool {
return true
}
func bundle(buf io.Writer, paths []string, flat bool) error {
func bundle(buf io.Writer, paths []string, rootDir string, flat bool) error {
gw := gzip.NewWriter(buf)
defer gw.Close()
tw := tar.NewWriter(gw)
@ -81,9 +83,9 @@ func bundle(buf io.Writer, paths []string, flat bool) error {
// https://golang.org/src/archive/tar/common.go?#L626
if !flat {
if !strings.HasPrefix(file, "/") {
file = "configui/" + file
file = rootDir + "/" + file
} else {
file = "configui" + file
file = rootDir + file
}
header.Name = file
}
@ -109,10 +111,19 @@ func bundle(buf io.Writer, paths []string, flat bool) error {
return nil
}
var ext2mode map[string]string = map[string]string{
"go": "golang",
"log": "sh",
"txt": "text",
"yml": "yaml",
"conf": "ini",
func GetIP(r *http.Request) string {
ip := r.Header.Get("X-Real-Ip")
if ip == "" {
ips := r.Header.Get("X-Forwarded-For")
ipArr := strings.Split(ips, ",")
ip = strings.Trim(ipArr[len(ipArr)-1], " ")
}
if ip == "" {
var err error
ip, _, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
}
return ip
}