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