webui.go

  1package commands
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"net"
  9	"net/http"
 10	"net/url"
 11	"strconv"
 12	"time"
 13
 14	"github.com/99designs/gqlgen/graphql/playground"
 15	"github.com/gorilla/mux"
 16	"github.com/phayes/freeport"
 17	"github.com/skratchdot/open-golang/open"
 18	"github.com/spf13/cobra"
 19
 20	"github.com/git-bug/git-bug/api/auth"
 21	"github.com/git-bug/git-bug/api/auth/provider"
 22	"github.com/git-bug/git-bug/api/graphql"
 23	httpapi "github.com/git-bug/git-bug/api/http"
 24	"github.com/git-bug/git-bug/cache"
 25	"github.com/git-bug/git-bug/commands/execenv"
 26	"github.com/git-bug/git-bug/entities/identity"
 27	"github.com/git-bug/git-bug/repository"
 28	"github.com/git-bug/git-bug/webui2"
 29)
 30
 31const webUIOpenConfigKey = "git-bug.webui.open"
 32
 33type webUIOptions struct {
 34	bind      string
 35	port      int
 36	open      bool
 37	noOpen    bool
 38	readOnly  bool
 39	logErrors bool
 40	query     string
 41
 42	// OAuth provider credentials. A provider is enabled when both its
 43	// client-id and client-secret are non-empty. Multiple providers can be
 44	// active simultaneously.
 45	githubClientId     string
 46	githubClientSecret string
 47}
 48
 49func newWebUICommand(env *execenv.Env) *cobra.Command {
 50	options := webUIOptions{}
 51
 52	cmd := &cobra.Command{
 53		Use:   "webui",
 54		Short: "Launch the web UI",
 55		Long: `Launch the web UI.
 56
 57Available git config:
 58  git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
 59`,
 60		PreRunE: execenv.LoadRepo(env),
 61		RunE: func(cmd *cobra.Command, args []string) error {
 62			return runWebUI(env, options)
 63		},
 64	}
 65
 66	flags := cmd.Flags()
 67	flags.SortFlags = false
 68
 69	flags.StringVar(&options.bind, "bind", "127.0.0.1", "Network address to bind to (default to 127.0.0.1)")
 70	flags.IntVarP(&options.port, "port", "p", 0, "Port to listen on (default to random available port)")
 71	flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
 72	flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
 73	flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
 74	flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
 75	flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
 76
 77	// GitHub OAuth: both flags must be provided together to enable GitHub login.
 78	flags.StringVar(&options.githubClientId, "github-client-id", "", "GitHub OAuth application client ID (enables GitHub login)")
 79	flags.StringVar(&options.githubClientSecret, "github-client-secret", "", "GitHub OAuth application client secret")
 80	cmd.MarkFlagsRequiredTogether("github-client-id", "github-client-secret")
 81
 82	return cmd
 83}
 84
 85// setupRoutes builds the router and registers all API and UI routes.
 86func setupRoutes(env *execenv.Env, opts webUIOptions, baseURL string) (*mux.Router, func() error, error) {
 87	// Collect enabled login providers.
 88	var providers []provider.Provider
 89	if opts.githubClientId != "" {
 90		providers = append(providers, provider.NewGitHub(opts.githubClientId, opts.githubClientSecret))
 91	}
 92
 93	// Determine auth mode and configure middleware accordingly.
 94	var authMode string
 95	var sessions *auth.SessionStore
 96	router := mux.NewRouter()
 97
 98	switch {
 99	case opts.readOnly:
100		authMode = "readonly"
101		// No middleware: every request is unauthenticated.
102
103	case len(providers) > 0:
104		authMode = "external"
105		sessions = auth.NewSessionStore()
106		router.Use(auth.SessionMiddleware(sessions))
107
108	default:
109		authMode = "local"
110		// Single-user mode: inject the identity from git config for every request.
111		author, err := identity.GetUserIdentity(env.Repo)
112		if err != nil {
113			return nil, nil, err
114		}
115		router.Use(auth.Middleware(author.Id()))
116	}
117
118	mrc := cache.NewMultiRepoCache()
119	_, events := mrc.RegisterDefaultRepository(env.Repo)
120	if err := execenv.CacheBuildProgressBar(env, events); err != nil {
121		return nil, nil, err
122	}
123
124	var errOut io.Writer
125	if opts.logErrors {
126		errOut = env.Err
127	}
128
129	// Collect provider names for GraphQL serverConfig.
130	providerNames := make([]string, len(providers))
131	for i, p := range providers {
132		providerNames[i] = p.Name()
133	}
134
135	graphqlHandler := graphql.NewHandler(mrc, graphql.ServerConfig{
136		AuthMode:       authMode,
137		LoginProviders: providerNames,
138	}, errOut)
139
140	// Register OAuth routes before the catch-all static handler.
141	if authMode == "external" {
142		ah := httpapi.NewAuthHandler(mrc, sessions, providers, baseURL)
143		router.Path("/auth/login").Methods("GET").HandlerFunc(ah.HandleLogin)
144		router.Path("/auth/callback").Methods("GET").HandlerFunc(ah.HandleCallback)
145		router.Path("/auth/user").Methods("GET").HandlerFunc(ah.HandleUser)
146		router.Path("/auth/logout").Methods("POST").HandlerFunc(ah.HandleLogout)
147		router.Path("/auth/identities").Methods("GET").HandlerFunc(ah.HandleIdentities)
148		router.Path("/auth/adopt").Methods("POST").HandlerFunc(ah.HandleAdopt)
149	}
150
151	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
152	router.Path("/graphql").Handler(graphqlHandler)
153
154	// File and upload routes for bug attachments.
155	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
156	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
157
158	router.PathPrefix("/").Handler(webui2.NewHandler())
159
160	return router, mrc.Close, nil
161}
162
163func runWebUI(env *execenv.Env, opts webUIOptions) error {
164	if opts.port == 0 {
165		var err error
166		opts.port, err = freeport.GetFreePort()
167		if err != nil {
168			return err
169		}
170	}
171
172	addr := net.JoinHostPort(opts.bind, strconv.Itoa(opts.port))
173	baseURL := "http://" + addr
174
175	router, closeRoutes, err := setupRoutes(env, opts, baseURL)
176	if err != nil {
177		return err
178	}
179	defer func() {
180		if err := closeRoutes(); err != nil {
181			env.Err.Println(err)
182		}
183	}()
184
185	server := &http.Server{Addr: addr, Handler: router}
186
187	env.Out.Printf("Web UI: %s\n", baseURL)
188	env.Out.Printf("Graphql API: %s/graphql\n", baseURL)
189	env.Out.Printf("Graphql Playground: %s/playground\n", baseURL)
190	if opts.githubClientId != "" {
191		env.Out.Printf("Login callback URL: %s/auth/callback\n", baseURL)
192		env.Out.Println("  ↳ Register this URL in your OAuth/OIDC application settings")
193	}
194	env.Out.Printf("\n[ Press Ctrl+c to quit ]\n\n")
195
196	toOpen := baseURL
197	if len(opts.query) > 0 {
198		toOpen = fmt.Sprintf("%s/?q=%s", baseURL, url.QueryEscape(opts.query))
199	}
200	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
201	if errors.Is(err, repository.ErrNoConfigEntry) {
202		// default to true
203		configOpen = true
204	} else if err != nil {
205		return err
206	}
207	if (configOpen && !opts.noOpen) || opts.open {
208		go openWhenUp(env, toOpen)
209	}
210
211	go func() {
212		<-env.Ctx.Done()
213		env.Out.Println("shutting down...")
214		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
215		defer cancel()
216		server.SetKeepAlivesEnabled(false)
217		if err := server.Shutdown(shutdownCtx); err != nil {
218			env.Err.Printf("Could not gracefully shutdown the HTTP server: %v\n", err)
219		}
220	}()
221
222	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
223		return err
224	}
225	return nil
226}
227
228func openWhenUp(env *execenv.Env, toOpen string) {
229	const maxAttempts = 3
230	if isUp(toOpen, maxAttempts, 3*time.Second) {
231		if err := open.Run(toOpen); err != nil {
232			env.Err.Println(err)
233			return
234		}
235		env.Out.Printf("opened your default browser to url: %s\n", toOpen)
236		return
237	}
238	env.Err.Printf(
239		"uh oh! it appears that the http server hasn't started.\n"+
240			"we failed to reach %s after %d attempts.\n",
241		toOpen, maxAttempts,
242	)
243}
244
245func isUp(url string, maxRetries int, initialDelay time.Duration) bool {
246	client := &http.Client{
247		Timeout: 5 * time.Second,
248	}
249
250	delay := initialDelay
251
252	for attempt := 1; attempt <= maxRetries; attempt++ {
253		resp, err := client.Head(url)
254		if err == nil {
255			_ = resp.Body.Close()
256			if resp.StatusCode >= 200 && resp.StatusCode < 400 {
257				return true
258			}
259		}
260
261		if attempt < maxRetries {
262			time.Sleep(delay)
263			delay *= 2
264		}
265	}
266
267	return false
268}