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