webui.go

  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/handler"
 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(handler.Playground("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.ReadConfigBool(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: loadRepo,
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}