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