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 "time"
16
17 "github.com/99designs/gqlgen/graphql/playground"
18 "github.com/gorilla/mux"
19 "github.com/phayes/freeport"
20 "github.com/skratchdot/open-golang/open"
21 "github.com/spf13/cobra"
22
23 "github.com/MichaelMure/git-bug/api/auth"
24 "github.com/MichaelMure/git-bug/api/graphql"
25 httpapi "github.com/MichaelMure/git-bug/api/http"
26 "github.com/MichaelMure/git-bug/cache"
27 "github.com/MichaelMure/git-bug/commands/execenv"
28 "github.com/MichaelMure/git-bug/entities/identity"
29 "github.com/MichaelMure/git-bug/repository"
30 "github.com/MichaelMure/git-bug/webui"
31)
32
33const webUIOpenConfigKey = "git-bug.webui.open"
34
35type webUIOptions struct {
36 host string
37 port int
38 open bool
39 noOpen bool
40 readOnly bool
41 logErrors bool
42 query string
43}
44
45func newWebUICommand() *cobra.Command {
46 env := execenv.NewEnv()
47 options := webUIOptions{}
48
49 cmd := &cobra.Command{
50 Use: "webui",
51 Short: "Launch the web UI",
52 Long: `Launch the web UI.
53
54Available git config:
55 git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
56`,
57 PreRunE: execenv.LoadRepo(env),
58 RunE: func(cmd *cobra.Command, args []string) error {
59 return runWebUI(env, options)
60 },
61 }
62
63 flags := cmd.Flags()
64 flags.SortFlags = false
65
66 flags.StringVar(&options.host, "host", "127.0.0.1", "Network address or hostname to listen to (default to 127.0.0.1)")
67 flags.BoolVar(&options.open, "open", false, "Automatically open the web UI in the default browser")
68 flags.BoolVar(&options.noOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
69 flags.IntVarP(&options.port, "port", "p", 0, "Port to listen to (default to random available port)")
70 flags.BoolVar(&options.readOnly, "read-only", false, "Whether to run the web UI in read-only mode")
71 flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
72 flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
73
74 return cmd
75}
76
77func runWebUI(env *execenv.Env, opts webUIOptions) error {
78 if opts.port == 0 {
79 var err error
80 opts.port, err = freeport.GetFreePort()
81 if err != nil {
82 return err
83 }
84 }
85
86 addr := net.JoinHostPort(opts.host, strconv.Itoa(opts.port))
87 webUiAddr := fmt.Sprintf("http://%s", addr)
88 toOpen := webUiAddr
89
90 if len(opts.query) > 0 {
91 // Explicitly set the query parameter instead of going with a default one.
92 toOpen = fmt.Sprintf("%s/?q=%s", webUiAddr, url.QueryEscape(opts.query))
93 }
94
95 router := mux.NewRouter()
96
97 // If the webUI is not read-only, use an authentication middleware with a
98 // fixed identity: the default user of the repo
99 // TODO: support dynamic authentication with OAuth
100 if !opts.readOnly {
101 author, err := identity.GetUserIdentity(env.Repo)
102 if err != nil {
103 return err
104 }
105 router.Use(auth.Middleware(author.Id()))
106 }
107
108 mrc := cache.NewMultiRepoCache()
109
110 _, events := mrc.RegisterDefaultRepository(env.Repo)
111 for event := range events {
112 if event.Err != nil {
113 env.Err.Printf("Cache building error [%s]: %v\n", event.Typename, event.Err)
114 continue
115 }
116 switch event.Event {
117 case cache.BuildEventCacheIsBuilt:
118 env.Err.Println("Building cache... ")
119 case cache.BuildEventStarted:
120 env.Err.Printf("[%s] started\n", event.Typename)
121 case cache.BuildEventFinished:
122 env.Err.Printf("[%s] done\n", event.Typename)
123 }
124 }
125
126 var errOut io.Writer
127 if opts.logErrors {
128 errOut = env.Err
129 }
130
131 graphqlHandler := graphql.NewHandler(mrc, errOut)
132
133 // Routes
134 router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
135 router.Path("/graphql").Handler(graphqlHandler)
136 router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
137 router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
138 router.PathPrefix("/").Handler(webui.NewHandler())
139
140 srv := &http.Server{
141 Addr: addr,
142 Handler: router,
143 }
144
145 done := make(chan bool)
146 quit := make(chan os.Signal, 1)
147
148 // register as handler of the interrupt signal to trigger the teardown
149 signal.Notify(quit, os.Interrupt)
150
151 go func() {
152 <-quit
153 env.Out.Println("WebUI is shutting down...")
154
155 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
156 defer cancel()
157
158 srv.SetKeepAlivesEnabled(false)
159 if err := srv.Shutdown(ctx); err != nil {
160 log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
161 }
162
163 // Teardown
164 err := graphqlHandler.Close()
165 if err != nil {
166 env.Out.Println(err)
167 }
168
169 close(done)
170 }()
171
172 env.Out.Printf("Web UI: %s\n", webUiAddr)
173 env.Out.Printf("Graphql API: http://%s/graphql\n", addr)
174 env.Out.Printf("Graphql Playground: http://%s/playground\n", addr)
175 env.Out.Println("Press Ctrl+c to quit")
176
177 configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey)
178 if errors.Is(err, repository.ErrNoConfigEntry) {
179 // default to true
180 configOpen = true
181 } else if err != nil {
182 return err
183 }
184
185 shouldOpen := (configOpen && !opts.noOpen) || opts.open
186
187 if shouldOpen {
188 err = open.Run(toOpen)
189 if err != nil {
190 env.Out.Println(err)
191 }
192 }
193
194 err = srv.ListenAndServe()
195 if err != nil && err != http.ErrServerClosed {
196 return err
197 }
198
199 <-done
200
201 env.Out.Println("WebUI stopped")
202 return nil
203}