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("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
197 apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
198
199 router.PathPrefix("/").Handler(webui2.NewHandler())
200
201 srv := &http.Server{
202 Addr: addr,
203 Handler: router,
204 }
205
206 done := make(chan bool)
207 quit := make(chan os.Signal, 1)
208
209 // register as handler of the interrupt signal to trigger the teardown
210 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
211
212 go func() {
213 <-quit
214 env.Out.Println("WebUI is shutting down...")
215
216 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
217 defer cancel()
218
219 srv.SetKeepAlivesEnabled(false)
220 if err := srv.Shutdown(ctx); err != nil {
221 log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
222 }
223
224 // Teardown
225 err = mrc.Close()
226 if err != nil {
227 env.Out.Println(err)
228 }
229
230 close(done)
231 }()
232
233 env.Out.Printf("Web UI: %s\n", webUiAddr)
234 env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
235 env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
236 if authMode == "oauth" {
237 env.Out.Printf("OAuth callback URL: %s/auth/callback\n", baseURL)
238 env.Out.Println(" ↳ Register this URL in your OAuth application settings")
239 }
240 env.Out.Println("Press Ctrl+c to quit")
241
242 configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
243 if errors.Is(err, repository.ErrNoConfigEntry) {
244 // default to true
245 configOpen = true
246 } else if err != nil {
247 return err
248 }
249
250 shouldOpen := (configOpen && !opts.noOpen) || opts.open
251
252 if shouldOpen {
253 err = open.Run(toOpen)
254 if err != nil {
255 env.Out.Println(err)
256 }
257 }
258
259 err = srv.ListenAndServe()
260 if err != nil && err != http.ErrServerClosed {
261 return err
262 }
263
264 <-done
265
266 env.Out.Println("WebUI stopped")
267 return nil
268}