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.PathPrefix("/gitraw/{repo}/{ref}/{path:.*}").Handler(httpapi.NewGitRawHandler(mrc))
157	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
158
159	router.PathPrefix("/").Handler(webui2.NewHandler())
160
161	return router, mrc.Close, nil
162}
163
164func runWebUI(env *execenv.Env, opts webUIOptions) error {
165	if opts.port == 0 {
166		var err error
167		opts.port, err = freeport.GetFreePort()
168		if err != nil {
169			return err
170		}
171	}
172
173	addr := net.JoinHostPort(opts.bind, strconv.Itoa(opts.port))
174	baseURL := "http://" + addr
175
176	router, closeRoutes, err := setupRoutes(env, opts, baseURL)
177	if err != nil {
178		return err
179	}
180	defer func() {
181		if err := closeRoutes(); err != nil {
182			env.Err.Println(err)
183		}
184	}()
185
186	server := &http.Server{Addr: addr, Handler: router}
187
188	env.Out.Printf("Web UI: %s\n", baseURL)
189	env.Out.Printf("Graphql API: %s/graphql\n", baseURL)
190	env.Out.Printf("Graphql Playground: %s/playground\n", baseURL)
191	if opts.githubClientId != "" {
192		env.Out.Printf("Login callback URL: %s/auth/callback\n", baseURL)
193		env.Out.Println("  ↳ Register this URL in your OAuth/OIDC application settings")
194	}
195	env.Out.Printf("\n[ Press Ctrl+c to quit ]\n\n")
196
197	toOpen := baseURL
198	if len(opts.query) > 0 {
199		toOpen = fmt.Sprintf("%s/?q=%s", baseURL, url.QueryEscape(opts.query))
200	}
201	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
202	if errors.Is(err, repository.ErrNoConfigEntry) {
203		// default to true
204		configOpen = true
205	} else if err != nil {
206		return err
207	}
208	if (configOpen && !opts.noOpen) || opts.open {
209		go openWhenUp(env, toOpen)
210	}
211
212	go func() {
213		<-env.Ctx.Done()
214		env.Out.Println("shutting down...")
215		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
216		defer cancel()
217		server.SetKeepAlivesEnabled(false)
218		if err := server.Shutdown(shutdownCtx); err != nil {
219			env.Err.Printf("Could not gracefully shutdown the HTTP server: %v\n", err)
220		}
221	}()
222
223	if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
224		return err
225	}
226	return nil
227}
228
229func openWhenUp(env *execenv.Env, toOpen string) {
230	const maxAttempts = 3
231	if isUp(toOpen, maxAttempts, 3*time.Second) {
232		if err := open.Run(toOpen); err != nil {
233			env.Err.Println(err)
234			return
235		}
236		env.Out.Printf("opened your default browser to url: %s\n", toOpen)
237		return
238	}
239	env.Err.Printf(
240		"uh oh! it appears that the http server hasn't started.\n"+
241			"we failed to reach %s after %d attempts.\n",
242		toOpen, maxAttempts,
243	)
244}
245
246func isUp(url string, maxRetries int, initialDelay time.Duration) bool {
247	client := &http.Client{
248		Timeout: 5 * time.Second,
249	}
250
251	delay := initialDelay
252
253	for attempt := 1; attempt <= maxRetries; attempt++ {
254		resp, err := client.Head(url)
255		if err == nil {
256			_ = resp.Body.Close()
257			if resp.StatusCode >= 200 && resp.StatusCode < 400 {
258				return true
259			}
260		}
261
262		if attempt < maxRetries {
263			time.Sleep(delay)
264			delay *= 2
265		}
266	}
267
268	return false
269}