webui.go

  1package commands
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"log"
  9	"net"
 10	"net/http"
 11	"net/url"
 12	"os"
 13	"os/signal"
 14	"strconv"
 15	"time"
 16
 17	"github.com/99designs/gqlgen/graphql/playground"
 18	"github.com/gorilla/mux"
 19	"github.com/phayes/freeport"
 20	"github.com/skratchdot/open-golang/open"
 21	"github.com/spf13/cobra"
 22
 23	"github.com/MichaelMure/git-bug/api/auth"
 24	"github.com/MichaelMure/git-bug/api/graphql"
 25	httpapi "github.com/MichaelMure/git-bug/api/http"
 26	"github.com/MichaelMure/git-bug/cache"
 27	"github.com/MichaelMure/git-bug/commands/execenv"
 28	"github.com/MichaelMure/git-bug/entities/identity"
 29	"github.com/MichaelMure/git-bug/repository"
 30	"github.com/MichaelMure/git-bug/webui"
 31)
 32
 33const webUIOpenConfigKey = "git-bug.webui.open"
 34
 35type webUIOptions struct {
 36	host      string
 37	port      int
 38	open      bool
 39	noOpen    bool
 40	readOnly  bool
 41	logErrors bool
 42	query     string
 43}
 44
 45func newWebUICommand() *cobra.Command {
 46	env := execenv.NewEnv()
 47	options := webUIOptions{}
 48
 49	cmd := &cobra.Command{
 50		Use:   "webui",
 51		Short: "Launch the web UI",
 52		Long: `Launch the web UI.
 53
 54Available git config:
 55  git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
 56`,
 57		PreRunE: execenv.LoadRepo(env),
 58		RunE: func(cmd *cobra.Command, args []string) error {
 59			return runWebUI(env, options)
 60		},
 61	}
 62
 63	flags := cmd.Flags()
 64	flags.SortFlags = false
 65
 66	flags.StringVar(&options.host, "host", "127.0.0.1", "Network address or hostname to listen to (default to 127.0.0.1)")
 67	flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
 68	flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
 69	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default to random available port)")
 70	flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
 71	flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
 72	flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
 73
 74	return cmd
 75}
 76
 77func runWebUI(env *execenv.Env, opts webUIOptions) error {
 78	if opts.port == 0 {
 79		var err error
 80		opts.port, err = freeport.GetFreePort()
 81		if err != nil {
 82			return err
 83		}
 84	}
 85
 86	addr := net.JoinHostPort(opts.host, strconv.Itoa(opts.port))
 87	webUiAddr := fmt.Sprintf("http://%s", addr)
 88	toOpen := webUiAddr
 89
 90	if len(opts.query) > 0 {
 91		// Explicitly set the query parameter instead of going with a default one.
 92		toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
 93	}
 94
 95	router := mux.NewRouter()
 96
 97	// If the webUI is not read-only, use an authentication middleware with a
 98	// fixed identity: the default user of the repo
 99	// TODO: support dynamic authentication with OAuth
100	if !opts.readOnly {
101		author, err := identity.GetUserIdentity(env.Repo)
102		if err != nil {
103			return err
104		}
105		router.Use(auth.Middleware(author.Id()))
106	}
107
108	mrc := cache.NewMultiRepoCache()
109
110	_, events := mrc.RegisterDefaultRepository(env.Repo)
111	for event := range events {
112		if event.Err != nil {
113			env.Err.Printf("Cache building error [%s]: %v\n", event.Typename, event.Err)
114			continue
115		}
116		switch event.Event {
117		case cache.BuildEventCacheIsBuilt:
118			env.Err.Println("Building cache... ")
119		case cache.BuildEventStarted:
120			env.Err.Printf("[%s] started\n", event.Typename)
121		case cache.BuildEventFinished:
122			env.Err.Printf("[%s] done\n", event.Typename)
123		}
124	}
125
126	var errOut io.Writer
127	if opts.logErrors {
128		errOut = env.Err
129	}
130
131	graphqlHandler := graphql.NewHandler(mrc, errOut)
132
133	// Routes
134	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
135	router.Path("/graphql").Handler(graphqlHandler)
136	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
137	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
138	router.PathPrefix("/").Handler(webui.NewHandler())
139
140	srv := &http.Server{
141		Addr:    addr,
142		Handler: router,
143	}
144
145	done := make(chan bool)
146	quit := make(chan os.Signal, 1)
147
148	// register as handler of the interrupt signal to trigger the teardown
149	signal.Notify(quit, os.Interrupt)
150
151	go func() {
152		<-quit
153		env.Out.Println("WebUI is shutting down...")
154
155		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
156		defer cancel()
157
158		srv.SetKeepAlivesEnabled(false)
159		if err := srv.Shutdown(ctx); err != nil {
160			log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
161		}
162
163		// Teardown
164		err := graphqlHandler.Close()
165		if err != nil {
166			env.Out.Println(err)
167		}
168
169		close(done)
170	}()
171
172	env.Out.Printf("Web UI: %s\n", webUiAddr)
173	env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
174	env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
175	env.Out.Println("Press Ctrl+c to quit")
176
177	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
178	if errors.Is(err, repository.ErrNoConfigEntry) {
179		// default to true
180		configOpen = true
181	} else if err != nil {
182		return err
183	}
184
185	shouldOpen := (configOpen && !opts.noOpen) || opts.open
186
187	if shouldOpen {
188		err = open.Run(toOpen)
189		if err != nil {
190			env.Out.Println(err)
191		}
192	}
193
194	err = srv.ListenAndServe()
195	if err != nil && err != http.ErrServerClosed {
196		return err
197	}
198
199	<-done
200
201	env.Out.Println("WebUI stopped")
202	return nil
203}