package webserver import ( "fmt" "net/http" "path" "time" "github.com/go-shiori/shiori/internal/database" "github.com/julienschmidt/httprouter" cch "github.com/patrickmn/go-cache" "github.com/sirupsen/logrus" ) // Config is parameter that used for starting web server type Config struct { DB database.DB DataDir string ServerAddress string ServerPort int RootPath string Log bool } // ErrorResponse defines a single HTTP error response. type ErrorResponse struct { Code int Body string contentType string errorText string Log bool } func (e *ErrorResponse) Error() string { return e.errorText } func (e *ErrorResponse) ServeHTTP(w http.ResponseWriter, r *http.Request) { if e.contentType != "" { w.Header().Set("Content-Type", e.contentType) } body := e.Body if e.Code != 0 { w.WriteHeader(e.Code) } written := 0 if len(body) > 0 { written, _ = w.Write([]byte(body)) } if e.Log { Logger(r, e.Code, written) } } // responseData will hold response details that we are interested in for logging type responseData struct { status int size int } // Wrapper around http.ResponseWriter to be able to catch calls to Write*() type loggingResponseWriter struct { http.ResponseWriter responseData *responseData } // Collect response size for each Write(). Also behave as the internal // http.ResponseWriter by implicitely setting the status code to 200 at the // first write. func (r *loggingResponseWriter) Write(b []byte) (int, error) { size, err := r.ResponseWriter.Write(b) // write response using original http.ResponseWriter r.responseData.size += size // capture size // Documented implicit WriteHeader(http.StatusOK) with first call to Write if r.responseData.status == 0 { r.responseData.status = http.StatusOK } return size, err } // Capture calls to WriteHeader, might be called on errors. func (r *loggingResponseWriter) WriteHeader(statusCode int) { r.ResponseWriter.WriteHeader(statusCode) // write status code using original http.ResponseWriter r.responseData.status = statusCode // capture status code } // Log through logrus, 200 will log as info, anything else as an error. func Logger(r *http.Request, statusCode int, size int) { if statusCode == http.StatusOK { logrus.WithFields(logrus.Fields{ "proto": r.Proto, "remote": r.RemoteAddr, "reqlen": r.ContentLength, "size": size, "status": statusCode, }).Info(r.Method, " ", r.RequestURI) } else { logrus.WithFields(logrus.Fields{ "proto": r.Proto, "remote": r.RemoteAddr, "reqlen": r.ContentLength, "size": size, "status": statusCode, }).Warn(r.Method, " ", r.RequestURI) } } // ServeApp serves web interface in specified port func ServeApp(cfg Config) error { // Create handler hdl := handler{ DB: cfg.DB, DataDir: cfg.DataDir, UserCache: cch.New(time.Hour, 10*time.Minute), SessionCache: cch.New(time.Hour, 10*time.Minute), ArchiveCache: cch.New(time.Minute, 5*time.Minute), RootPath: cfg.RootPath, Log: cfg.Log, } hdl.prepareSessionCache() hdl.prepareArchiveCache() err := hdl.prepareTemplates() if err != nil { return fmt.Errorf("failed to prepare templates: %v", err) } // Prepare errors var ( ErrorNotAllowed = &ErrorResponse{ http.StatusMethodNotAllowed, "Method is not allowed", "text/plain; charset=UTF-8", "MethodNotAllowedError", cfg.Log, } ErrorNotFound = &ErrorResponse{ http.StatusNotFound, "Resource Not Found", "text/plain; charset=UTF-8", "NotFoundError", cfg.Log, } ) // Create router and register error handlers router := httprouter.New() router.NotFound = ErrorNotFound router.MethodNotAllowed = ErrorNotAllowed // withLogging will inject our own (compatible) http.ResponseWriter in order // to collect details about the answer, i.e. the status code and the size of // data in the response. Once done, these are passed further for logging, if // relevant. withLogging := func(req func(http.ResponseWriter, *http.Request, httprouter.Params)) func(http.ResponseWriter, *http.Request, httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { d := &responseData{ status: 0, size: 0, } lrw := loggingResponseWriter{ ResponseWriter: w, responseData: d, } req(&lrw, r, ps) if hdl.Log { Logger(r, d.status, d.size) } } } // jp here means "join path", as in "join route with root path" jp := func(route string) string { return path.Join(cfg.RootPath, route) } router.GET(jp("/js/*filepath"), withLogging(hdl.serveJsFile)) router.GET(jp("/res/*filepath"), withLogging(hdl.serveFile)) router.GET(jp("/css/*filepath"), withLogging(hdl.serveFile)) router.GET(jp("/fonts/*filepath"), withLogging(hdl.serveFile)) router.GET(cfg.RootPath, withLogging(hdl.serveIndexPage)) router.GET(jp("/login"), withLogging(hdl.serveLoginPage)) router.GET(jp("/bookmark/:id/thumb"), withLogging(hdl.serveThumbnailImage)) router.GET(jp("/bookmark/:id/content"), withLogging(hdl.serveBookmarkContent)) router.GET(jp("/bookmark/:id/archive/*filepath"), withLogging(hdl.serveBookmarkArchive)) router.POST(jp("/api/login"), withLogging(hdl.apiLogin)) router.POST(jp("/api/logout"), withLogging(hdl.apiLogout)) router.GET(jp("/api/bookmarks"), withLogging(hdl.apiGetBookmarks)) router.GET(jp("/api/tags"), withLogging(hdl.apiGetTags)) router.PUT(jp("/api/tag"), withLogging(hdl.apiRenameTag)) router.POST(jp("/api/bookmarks"), withLogging(hdl.apiInsertBookmark)) router.DELETE(jp("/api/bookmarks"), withLogging(hdl.apiDeleteBookmark)) router.PUT(jp("/api/bookmarks"), withLogging(hdl.apiUpdateBookmark)) router.PUT(jp("/api/cache"), withLogging(hdl.apiUpdateCache)) router.PUT(jp("/api/bookmarks/tags"), withLogging(hdl.apiUpdateBookmarkTags)) router.POST(jp("/api/bookmarks/ext"), withLogging(hdl.apiInsertViaExtension)) router.DELETE(jp("/api/bookmarks/ext"), withLogging(hdl.apiDeleteViaExtension)) router.GET(jp("/api/accounts"), withLogging(hdl.apiGetAccounts)) router.PUT(jp("/api/accounts"), withLogging(hdl.apiUpdateAccount)) router.POST(jp("/api/accounts"), withLogging(hdl.apiInsertAccount)) router.DELETE(jp("/api/accounts"), withLogging(hdl.apiDeleteAccount)) // Route for panic, keep logging anyhow router.PanicHandler = func(w http.ResponseWriter, r *http.Request, arg interface{}) { d := &responseData{ status: 0, size: 0, } lrw := loggingResponseWriter{ ResponseWriter: w, responseData: d, } http.Error(&lrw, fmt.Sprint(arg), 500) if hdl.Log { Logger(r, d.status, d.size) } } // Create server url := fmt.Sprintf("%s:%d", cfg.ServerAddress, cfg.ServerPort) svr := &http.Server{ Addr: url, Handler: router, ReadTimeout: 10 * time.Second, WriteTimeout: time.Minute, } // Serve app logrus.Infoln("Serve shiori in", url, cfg.RootPath) return svr.ListenAndServe() }