webui.go

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