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