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