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}