1package commands
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io/ioutil"
9 "log"
10 "net/http"
11 "os"
12 "os/signal"
13 "time"
14
15 "github.com/99designs/gqlgen/graphql/playground"
16 "github.com/gorilla/mux"
17 "github.com/phayes/freeport"
18 "github.com/skratchdot/open-golang/open"
19 "github.com/spf13/cobra"
20
21 "github.com/MichaelMure/git-bug/graphql"
22 "github.com/MichaelMure/git-bug/graphql/config"
23 "github.com/MichaelMure/git-bug/repository"
24 "github.com/MichaelMure/git-bug/util/git"
25 "github.com/MichaelMure/git-bug/webui"
26)
27
28var (
29 webUIPort int
30 webUIOpen bool
31 webUINoOpen bool
32 webUIReadOnly bool
33)
34
35const webUIOpenConfigKey = "git-bug.webui.open"
36
37func runWebUI(cmd *cobra.Command, args []string) error {
38 if webUIPort == 0 {
39 var err error
40 webUIPort, err = freeport.GetFreePort()
41 if err != nil {
42 return err
43 }
44 }
45
46 addr := fmt.Sprintf("127.0.0.1:%d", webUIPort)
47 webUiAddr := fmt.Sprintf("http://%s", addr)
48
49 router := mux.NewRouter()
50
51 graphqlHandler, err := graphql.NewHandler(repo, config.Config{ReadOnly: webUIReadOnly})
52 if err != nil {
53 return err
54 }
55
56 assetsHandler := &fileSystemWithDefault{
57 FileSystem: webui.WebUIAssets,
58 defaultFile: "index.html",
59 }
60
61 // Routes
62 router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
63 router.Path("/graphql").Handler(graphqlHandler)
64 router.Path("/gitfile/{hash}").Handler(newGitFileHandler(repo))
65 if !webUIReadOnly {
66 router.Path("/upload").Methods("POST").Handler(newGitUploadFileHandler(repo))
67 }
68 router.PathPrefix("/").Handler(http.FileServer(assetsHandler))
69
70 srv := &http.Server{
71 Addr: addr,
72 Handler: router,
73 }
74
75 done := make(chan bool)
76 quit := make(chan os.Signal, 1)
77
78 // register as handler of the interrupt signal to trigger the teardown
79 signal.Notify(quit, os.Interrupt)
80
81 go func() {
82 <-quit
83 fmt.Println("WebUI is shutting down...")
84
85 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
86 defer cancel()
87
88 srv.SetKeepAlivesEnabled(false)
89 if err := srv.Shutdown(ctx); err != nil {
90 log.Fatalf("Could not gracefully shutdown the WebUI: %v\n", err)
91 }
92
93 // Teardown
94 err := graphqlHandler.Close()
95 if err != nil {
96 fmt.Println(err)
97 }
98
99 close(done)
100 }()
101
102 fmt.Printf("Web UI: %s\n", webUiAddr)
103 fmt.Printf("Graphql API: http://%s/graphql\n", addr)
104 fmt.Printf("Graphql Playground: http://%s/playground\n", addr)
105 fmt.Println("Press Ctrl+c to quit")
106
107 configOpen, err := repo.LocalConfig().ReadBool(webUIOpenConfigKey)
108 if err == repository.ErrNoConfigEntry {
109 // default to true
110 configOpen = true
111 } else if err != nil {
112 return err
113 }
114
115 shouldOpen := (configOpen && !webUINoOpen) || webUIOpen
116
117 if shouldOpen {
118 err = open.Run(webUiAddr)
119 if err != nil {
120 fmt.Println(err)
121 }
122 }
123
124 err = srv.ListenAndServe()
125 if err != nil && err != http.ErrServerClosed {
126 return err
127 }
128
129 <-done
130
131 fmt.Println("WebUI stopped")
132 return nil
133}
134
135// implement a http.FileSystem that will serve a default file when the looked up
136// file doesn't exist. Useful for Single-Page App that implement routing client
137// side, where the server has to return the root index.html file for every route.
138type fileSystemWithDefault struct {
139 http.FileSystem
140 defaultFile string
141}
142
143func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
144 f, err := fswd.FileSystem.Open(name)
145 if os.IsNotExist(err) {
146 return fswd.FileSystem.Open(fswd.defaultFile)
147 }
148 return f, err
149}
150
151// implement a http.Handler that will read and server git blob.
152type gitFileHandler struct {
153 repo repository.Repo
154}
155
156func newGitFileHandler(repo repository.Repo) http.Handler {
157 return &gitFileHandler{
158 repo: repo,
159 }
160}
161
162func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
163 hash := git.Hash(mux.Vars(r)["hash"])
164
165 if !hash.IsValid() {
166 http.Error(rw, "invalid git hash", http.StatusBadRequest)
167 return
168 }
169
170 // TODO: this mean that the whole file will he buffered in memory
171 // This can be a problem for big files. There might be a way around
172 // that by implementing a io.ReadSeeker that would read and discard
173 // data when a seek is called.
174 data, err := gfh.repo.ReadData(git.Hash(hash))
175 if err != nil {
176 http.Error(rw, err.Error(), http.StatusInternalServerError)
177 return
178 }
179
180 http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
181}
182
183// implement a http.Handler that will accept and store content into git blob.
184type gitUploadFileHandler struct {
185 repo repository.Repo
186}
187
188func newGitUploadFileHandler(repo repository.Repo) http.Handler {
189 return &gitUploadFileHandler{
190 repo: repo,
191 }
192}
193
194func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
195 // 100MB (github limit)
196 var maxUploadSize int64 = 100 * 1000 * 1000
197 r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
198 if err := r.ParseMultipartForm(maxUploadSize); err != nil {
199 http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
200 return
201 }
202
203 file, _, err := r.FormFile("uploadfile")
204 if err != nil {
205 http.Error(rw, "invalid file", http.StatusBadRequest)
206 return
207 }
208 defer file.Close()
209 fileBytes, err := ioutil.ReadAll(file)
210 if err != nil {
211 http.Error(rw, "invalid file", http.StatusBadRequest)
212 return
213 }
214
215 filetype := http.DetectContentType(fileBytes)
216 if filetype != "image/jpeg" && filetype != "image/jpg" &&
217 filetype != "image/gif" && filetype != "image/png" {
218 http.Error(rw, "invalid file type", http.StatusBadRequest)
219 return
220 }
221
222 hash, err := gufh.repo.StoreData(fileBytes)
223 if err != nil {
224 http.Error(rw, err.Error(), http.StatusInternalServerError)
225 return
226 }
227
228 type response struct {
229 Hash string `json:"hash"`
230 }
231
232 resp := response{Hash: string(hash)}
233
234 js, err := json.Marshal(resp)
235 if err != nil {
236 http.Error(rw, err.Error(), http.StatusInternalServerError)
237 return
238 }
239
240 rw.Header().Set("Content-Type", "application/json")
241 _, err = rw.Write(js)
242 if err != nil {
243 http.Error(rw, err.Error(), http.StatusInternalServerError)
244 return
245 }
246}
247
248var webUICmd = &cobra.Command{
249 Use: "webui",
250 Short: "Launch the web UI.",
251 Long: `Launch the web UI.
252
253Available git config:
254 git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser
255`,
256 PreRunE: loadRepoEnsureUser,
257 RunE: runWebUI,
258}
259
260func init() {
261 RootCmd.AddCommand(webUICmd)
262
263 webUICmd.Flags().SortFlags = false
264
265 webUICmd.Flags().BoolVar(&webUIOpen, "open", false, "Automatically open the web UI in the default browser")
266 webUICmd.Flags().BoolVar(&webUINoOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
267 webUICmd.Flags().IntVarP(&webUIPort, "port", "p", 0, "Port to listen to (default is random)")
268 webUICmd.Flags().BoolVar(&webUIReadOnly, "read-only", false, "Whether to run the web UI in read-only mode")
269
270}