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/webui2"
 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	// Top-level API routes
176	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
177	router.Path("/graphql").Handler(graphqlHandler)
178
179	// /api/repos/{owner}/{repo}/ subrouter.
180	// owner is reserved for future use; "_" means "local".
181	// repo "_" resolves to the default repository.
182	//
183	// In oauth mode all API endpoints require a valid session, making the
184	// server safe to deploy publicly. In local and readonly modes the
185	// middleware only injects identity without blocking.
186	apiRepos := router.PathPrefix("/api/repos/{owner}/{repo}").Subrouter()
187	if authMode == "oauth" {
188		apiRepos.Use(auth.RequireAuth)
189	}
190	apiRepos.Path("/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc))
191	apiRepos.Path("/git/trees/{ref}").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc))
192	apiRepos.Path("/git/blobs/{ref}").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc))
193	apiRepos.Path("/git/raw/{ref}/{path:.*}").Methods("GET").Handler(httpapi.NewGitRawHandler(mrc))
194	apiRepos.Path("/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc))
195	apiRepos.Path("/git/commits/{sha}").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc))
196	apiRepos.Path("/git/commits/{sha}/diff").Methods("GET").Handler(httpapi.NewGitCommitDiffHandler(mrc))
197	apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
198	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
199
200	router.PathPrefix("/").Handler(webui2.NewHandler())
201
202	srv := &http.Server{
203		Addr:    addr,
204		Handler: router,
205	}
206
207	done := make(chan bool)
208	quit := make(chan os.Signal, 1)
209
210	// register as handler of the interrupt signal to trigger the teardown
211	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
212
213	go func() {
214		<-quit
215		env.Out.Println("WebUI is shutting down...")
216
217		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
218		defer cancel()
219
220		srv.SetKeepAlivesEnabled(false)
221		if err := srv.Shutdown(ctx); err != nil {
222			log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
223		}
224
225		// Teardown
226		err = mrc.Close()
227		if err != nil {
228			env.Out.Println(err)
229		}
230
231		close(done)
232	}()
233
234	env.Out.Printf("Web UI: %s\n", webUiAddr)
235	env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
236	env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
237	if authMode == "oauth" {
238		env.Out.Printf("OAuth callback URL: %s/auth/callback\n", baseURL)
239		env.Out.Println("  ↳ Register this URL in your OAuth application settings")
240	}
241	env.Out.Println("Press Ctrl+c to quit")
242
243	configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
244	if errors.Is(err, repository.ErrNoConfigEntry) {
245		// default to true
246		configOpen = true
247	} else if err != nil {
248		return err
249	}
250
251	shouldOpen := (configOpen && !opts.noOpen) || opts.open
252
253	if shouldOpen {
254		err = open.Run(toOpen)
255		if err != nil {
256			env.Out.Println(err)
257		}
258	}
259
260	err = srv.ListenAndServe()
261	if err != nil && err != http.ErrServerClosed {
262		return err
263	}
264
265	<-done
266
267	env.Out.Println("WebUI stopped")
268	return nil
269}