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