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}