# HG changeset patch # User Christophe de Vienne <christophe@cdevienne.info> # Date 1731966733 -3600 # Mon Nov 18 22:52:13 2024 +0100 # Node ID 5a251df9bb98f7eb2ca1587c14f4b2480718a943 # Parent 18f8a09aa90315ddc53b60364b9f9fe434427928 cmd: Add 'version' command & UI options diff --git a/cmd/program.go b/cmd/program.go --- a/cmd/program.go +++ b/cmd/program.go @@ -38,6 +38,7 @@ } type Program struct { + Version Version BootstrapParser *flags.Parser Parser *flags.Parser @@ -58,10 +59,14 @@ hasDB bool dbMigrateSource source.Driver + middlewares []Middleware + setupXbusActors func(*Program) []XbusActorFactory xbusActorNames []string setupHandler func(*Program) http.Handler setupSubcommands []subcommand + + postInit []func(*Program) } type Option func(program *Program) @@ -72,6 +77,38 @@ } } +type Middleware interface { + Middleware(http.Handler) (http.Handler, error) +} + +type middlewareFunc func(http.Handler) (http.Handler, error) + +func (f middlewareFunc) Middleware(next http.Handler) (http.Handler, error) { + return f(next) +} + +type middlewareFuncNoErr func(http.Handler) http.Handler + +func (f middlewareFuncNoErr) Middleware(next http.Handler) (http.Handler, error) { + return f(next), nil +} + +func WithMiddleware(middleware any) Option { + var m Middleware + switch f := middleware.(type) { + case func(http.Handler) http.Handler: + m = middlewareFuncNoErr(f) + case func(http.Handler) (http.Handler, error): + m = middlewareFunc(f) + default: + panic(fmt.Errorf("Invalid middleware type: %t", middleware)) + } + + return func(program *Program) { + program.middlewares = append(program.middlewares, m) + } +} + func WithXbusActors(factories func(*Program) []XbusActorFactory) Option { return func(program *Program) { program.setupXbusActors = factories @@ -103,11 +140,18 @@ } } -func NewProgram(name string, options ...Option) *Program { +type Version struct { + Version string + Hash string + Build string +} + +func NewProgram(name string, version Version, options ...Option) *Program { bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown) parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash) program := Program{ + Version: version, BootstrapParser: bootstrapParser, Parser: parser, Logger: orusapi.DefaultLogger(os.Stdout), @@ -148,6 +192,7 @@ program.ServeCmd = SetupServeCmd(&program) program.GenerateConfigCmd = SetupGenerateConfigCmd(&program) + _ = SetupVersionCmd(&program) if program.hasDB { g, err := parser.AddGroup("Database", "Database options", &program.DatabaseOptions) @@ -166,9 +211,19 @@ } } + for _, pi := range program.postInit { + pi(&program) + } + return &program } +func PostInit(pi func(program *Program)) func(program *Program) { + return func(program *Program) { + program.postInit = append(program.postInit, pi) + } +} + func (program *Program) ParseArgs(args []string) int { if _, err := program.BootstrapParser.ParseArgs(args); err != nil { program.Logger.Err(err).Msg("could not parse command line") diff --git a/cmd/program_ui.go b/cmd/program_ui.go new file mode 100644 --- /dev/null +++ b/cmd/program_ui.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "io/fs" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/timewasted/go-accept-headers" + + "orus.io/orus-io/go-orusapi" +) + +type UIOptions struct { + fs fs.FS + paths []string + + External string `long:"ui-external" ini-name:"ui-ui-external" description:"UI external server"` +} + +func NewIgnoreNotFoundResponseWriter(rw http.ResponseWriter) *IgnoreNotFoundResponseWriter { + return &IgnoreNotFoundResponseWriter{ + header: rw.Header().Clone(), + } +} + +type IgnoreNotFoundResponseWriter struct { + header http.Header + notfound *bool + next http.ResponseWriter +} + +func (rw *IgnoreNotFoundResponseWriter) NotFound() bool { + return rw.notfound != nil && *rw.notfound +} + +func (rw *IgnoreNotFoundResponseWriter) Header() http.Header { + return rw.header +} + +func (rw *IgnoreNotFoundResponseWriter) flushHeader() { + nh := rw.next.Header() + for k := range nh { + if _, ok := rw.header[k]; !ok { + nh.Del(k) + } + } + for k, v := range rw.header { + nh[k] = v + } +} + +func (rw *IgnoreNotFoundResponseWriter) WriteHeader(statusCode int) { + notFound := statusCode == http.StatusNotFound + rw.notfound = ¬Found + if !notFound { + rw.flushHeader() + rw.next.WriteHeader(statusCode) + } +} + +func (rw *IgnoreNotFoundResponseWriter) Write(data []byte) (int, error) { + if rw.notfound == nil { + var value bool + rw.notfound = &value + rw.flushHeader() + } + if *rw.notfound { + return 0, nil + } + + return rw.next.Write(data) +} + +func WithUI(uifs fs.FS, prefix string) Option { + return PostInit(func(program *Program) { + uiOptions := UIOptions{ + fs: uifs, + } + var serveFound bool + for _, cmd := range program.Parser.Commands() { + if cmd.Name == "serve" { + _, err := cmd.AddGroup("ui", "User Interface", &uiOptions) + if err != nil { + panic(err) + } + serveFound = true + + break + } + } + if !serveFound { + panic("serve command not found") + } + middleware := func(next http.Handler) (http.Handler, error) { + var uiHandler http.Handler + if uiOptions.External == "" { + uiHandler = orusapi.NewSPAFileServer( + http.FS(uiOptions.fs), + prefix, + ) + } else { + u, err := url.Parse(uiOptions.External) + if err != nil { + return nil, err + } + uiHandler = httputil.NewSingleHostReverseProxy(u) + } + if prefix != "" { + uiHandler = http.StripPrefix(prefix, uiHandler) + } + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if prefix != "" { + if !strings.HasPrefix(r.URL.Path, prefix) { + next.ServeHTTP(rw, r) + } + } + accepted, err := accept.Negotiate( + r.Header.Get("Accept"), + "text/html", "application/json") + if err != nil || accepted == "application/json" { + next.ServeHTTP(rw, r) + } else { + rwWrapper := NewIgnoreNotFoundResponseWriter(rw) + uiHandler.ServeHTTP(rwWrapper, r) + + if rwWrapper.NotFound() { + next.ServeHTTP(rw, r) + } + } + }), nil + } + WithMiddleware(middleware)(program) + }) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" +) + +func SetupVersionCmd(program *Program) *VersionCmd { + cmd := VersionCmd{ + program: program, + } + + if _, err := program.Parser.AddCommand( + "version", "Show the program version", "", &cmd, + ); err != nil { + program.Logger.Fatal().Msg(err.Error()) + } + + return &cmd +} + +type VersionCmd struct { + program *Program + + Verbose bool `short:"v" long:"verbose" description:"display the source hash and build number if available"` +} + +func (cmd *VersionCmd) Execute([]string) error { + + versionLine := cmd.program.Version.Version + if cmd.Verbose { + if cmd.program.Version.Hash != "" { + versionLine += "\nHash:" + cmd.program.Version.Hash + } + if cmd.program.Version.Build != "" { + versionLine += "\nBuild:" + cmd.program.Version.Build + } + } + if _, err := fmt.Println(versionLine); err != nil { + return err + } + + return nil +} diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.16.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.13.0 // indirect golang.org/x/sys v0.21.0 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -450,6 +450,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw= +github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09/go.mod h1:Uy/Rnv5WKuOO+PuDhuYLEpUiiKIZtss3z519uk67aF0= github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/spa_fileserver.go b/spa_fileserver.go new file mode 100644 --- /dev/null +++ b/spa_fileserver.go @@ -0,0 +1,209 @@ +package orusapi + +import ( + "mime" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +func NewSPAFileServer(root http.FileSystem, version string) http.Handler { + return &SPAFileHandler{root, version} +} + +type SPAFileHandler struct { + root http.FileSystem + etag string +} + +var encodingWeight = map[string]int{ + "deflate": 1, + "gzip": 2, + "br": 3, +} + +func acceptEncoding(r *http.Request) []string { + s := r.Header.Get("Accept-Encoding") + splitted := strings.Split(s, ",") + for i := range splitted { + splitted[i] = strings.Trim(splitted[i], " ") + } + sort.Slice(splitted, func(i, j int) bool { + return encodingWeight[splitted[i]] > encodingWeight[splitted[j]] + }) + + return splitted +} + +func encoding(encoding string) (string, string) { + switch encoding { + case "br": + return "br", ".br" + case "gzip": + return "gzip", ".gz" + case "deflate": + return "", "" + default: + return "", "" + } +} + +func (h *SPAFileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + upath := r.URL.Path + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + r.URL.Path = upath + } + _, debugMode := r.URL.Query()["debug"] + + h.serveFile(w, r, path.Clean(upath), true, debugMode) +} + +// localRedirect gives a Moved Permanently response. +// It does not convert relative paths to absolute paths like Redirect does. +func (h *SPAFileHandler) localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { + if q := r.URL.RawQuery; q != "" { + newPath += "?" + q + } + w.Header().Set("Location", newPath) + w.WriteHeader(http.StatusMovedPermanently) +} + +func (h *SPAFileHandler) open(name string, acceptEncoding []string) (http.File, string, error) { + for _, enc := range acceptEncoding { + contentEncoding, ext := encoding(enc) + f, err := h.root.Open(name + ext) + if err == nil { + return f, contentEncoding, nil + } + if !os.IsNotExist(err) { + return nil, "", err + } + } + + return nil, "", os.ErrNotExist +} + +func (h *SPAFileHandler) serveFile(w http.ResponseWriter, r *http.Request, name string, redirect bool, debugMode bool) { + ifNoneMatch := r.Header.Get("If-None-Match") + if ifNoneMatch == h.etag { + w.WriteHeader(http.StatusNotModified) + + return + } + indexPage := "/index.html" + if debugMode { + indexPage = "/index.debug.html" + } + + if strings.HasPrefix(r.URL.Path, "/payment/") { + indexPage = "/payment.html" + if debugMode { + indexPage = "/payment.debug.html" + } + } + + // redirect .../index.html to .../ + // can't use Redirect() because that would make the path absolute, + // which would be a problem running under StripPrefix + if strings.HasSuffix(r.URL.Path, indexPage) { + h.localRedirect(w, r, "./") + + return + } + + f, contentEncoding, err := h.open(name, acceptEncoding(r)) + if os.IsNotExist(err) { + f, contentEncoding, err = h.open(indexPage, acceptEncoding(r)) + if err != nil { + name = indexPage + } + } + if err != nil { + msg, code := toHTTPError(err) + http.Error(w, msg, code) + + return + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + msg, code := toHTTPError(err) + http.Error(w, msg, code) + + return + } + + if redirect { + // redirect to canonical path: / at end of directory url + // r.URL.Path always begins with / + url := r.URL.Path + if d.IsDir() { + if url[len(url)-1] != '/' { + h.localRedirect(w, r, path.Base(url)+"/") + + return + } + } else { + if url[len(url)-1] == '/' { + h.localRedirect(w, r, "../"+path.Base(url)) + + return + } + } + } + if d.IsDir() { + url := r.URL.Path + // redirect if the directory name doesn't end in a slash + if url == "" || url[len(url)-1] != '/' { + h.localRedirect(w, r, path.Base(url)+"/") + + return + } + + // use contents of index.html for directory, if present + index := strings.TrimSuffix(name, "/") + indexPage + ff, ffContentEncoding, err := h.open(index, acceptEncoding(r)) + if err == nil { + defer ff.Close() + dd, err := ff.Stat() + if err == nil { + name = index + d = dd + f = ff + contentEncoding = ffContentEncoding + } + } + } + // Still a directory? (we didn't find an index.html file) + if d.IsDir() { + http.Error(w, "Not found", http.StatusNotFound) + + return + } + + w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(name))) + if contentEncoding != "" { + w.Header().Set("Content-Encoding", contentEncoding) + } + w.Header().Set("ETag", h.etag) + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, d.Name(), d.ModTime(), f) +} + +// The following code is copied from the golang 'http' package + +func toHTTPError(err error) (msg string, httpStatus int) { + if os.IsNotExist(err) { + return "404 page not found", http.StatusNotFound + } + if os.IsPermission(err) { + return "403 Forbidden", http.StatusForbidden + } + // Default: + return "500 Internal Server Error", http.StatusInternalServerError +} diff --git a/tools/build_version_file/main.go b/tools/build_version_file/main.go new file mode 100644 --- /dev/null +++ b/tools/build_version_file/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "text/template" +) + +func syscmd(name string, arg ...string) string { + cmd := exec.Command(name, arg...) + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Println(string(out)) + panic(err) + } + splitted := strings.Split(string(out), "\n") + lines := make([]string, 0, len(splitted)) + for _, l := range splitted { + l = strings.TrimSpace(l) + if l == "" { + continue + } + lines = append(lines, l) + } + if len(lines) != 1 { + fmt.Println(string(out)) + panic("Expects a single line") + } + + return lines[0] +} + +func getVersionTag(tags string) string { + var candidates []string + for _, t := range strings.Split(tags, " ") { + if strings.HasPrefix(t, "v") { + candidates = append(candidates, t) + } + } + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + +var outTemplate = template.Must(template.New("").Parse(`package {{ .package }} + +var Version = "{{ .version }}" +var HgSha = "{{ .sha }}" +var Build = "{{ .build }}" +`)) + +func main() { + sha := syscmd("hg", "id", "--id") + curVersion := getVersionTag(syscmd("hg", "id", "--tags")) + lastVersion := getVersionTag(syscmd("hg", "id", "--tags", "-r", "limit(last(ancestors(.)&tag('re:v.*'))|.,1)")) + modified := strings.HasSuffix(sha, "+") + jobID := os.Getenv("CI_JOB_ID") + + var version string + + switch { + case modified && lastVersion != "": + version = lastVersion + "" + case curVersion != "": + version = curVersion + case lastVersion != "": + version = lastVersion + "-" + sha + default: + version = "v0.0.0-" + sha + } + + vars := map[string]string{ + "package": "main", + "sha": sha, + "version": version, + "build": jobID, + } + + out, err := os.Create("version.go") + if err != nil { + panic(err) + } + + if err := outTemplate.Execute(out, vars); err != nil { + panic(err) + } +}