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