diff --git a/internal/cmd/server.go b/internal/cmd/server.go index 7637ee5..d32eccb 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -19,7 +19,7 @@ func newServerCommand() *cobra.Command { cmd.Flags().IntP("port", "p", 8080, "Port used by the server") cmd.Flags().StringP("address", "a", "", "Address the server listens to") cmd.Flags().StringP("webroot", "r", "/", "Root path that used by server") - cmd.Flags().Bool("access-log", true, "Print out a non-standard access log") + cmd.Flags().Bool("access-log", false, "Print out a non-standard access log") cmd.Flags().Bool("serve-web-ui", true, "Serve static files from the webroot path") cmd.Flags().String("secret-key", "", "Secret key used for encrypting session data") @@ -40,15 +40,7 @@ func newServerCommandHandler() func(cmd *cobra.Command, args []string) { cfg, dependencies := initShiori(ctx, cmd) - // Check HTTP configuration - // For now it will just log to the console, but in the future it will be fatal. The only required - // setting for now is the secret key. - if errs, isValid := cfg.Http.IsValid(); !isValid { - dependencies.Log.Error("Found some errors in configuration.For now server will start but this will be fatal in the future.") - for _, err := range errs { - dependencies.Log.WithError(err).Error("found invalid configuration") - } - } + cfg.Http.SetDefaults(dependencies.Log) // Validate root path if rootPath == "" { diff --git a/internal/config/config.go b/internal/config/config.go index 336ba5b..4e8abef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/gofrs/uuid" "github.com/sethvargo/go-envconfig" "github.com/sirupsen/logrus" ) @@ -78,13 +79,17 @@ type Config struct { Http *HttpConfig } -// IsValid checks if the configuration is valid -func (c HttpConfig) IsValid() (errs []error, isValid bool) { +// SetDefaults sets the default values for the configuration +func (c *HttpConfig) SetDefaults(logger *logrus.Logger) { + // Set a random secret key if not set if c.SecretKey == "" { - errs = append(errs, fmt.Errorf("SHIORI_HTTP_SECRET_KEY is required")) + logger.Warn("SHIORI_HTTP_SECRET_KEY is not set, using random value. This means that all sessions will be invalidated on server restart.") + randomUUID, err := uuid.NewV4() + if err != nil { + logger.WithError(err).Fatal("couldn't generate a random UUID") + } + c.SecretKey = randomUUID.String() } - - return errs, len(errs) == 0 } // SetDefaults sets the default values for the configuration diff --git a/internal/http/routes/legacy.go b/internal/http/routes/legacy.go index 7b7bea2..d9d3079 100644 --- a/internal/http/routes/legacy.go +++ b/internal/http/routes/legacy.go @@ -67,7 +67,7 @@ func (r *LegacyAPIRoutes) Setup(g *gin.Engine) { DataDir: r.cfg.Storage.DataDir, RootPath: r.cfg.Http.RootPath, Log: false, // Already done by gin - }) + }, r.deps) r.legacyHandler.PrepareSessionCache() r.legacyHandler.PrepareTemplates() diff --git a/internal/webserver/handler-api.go b/internal/webserver/handler-api.go index ac872b0..92da0f4 100644 --- a/internal/webserver/handler-api.go +++ b/internal/webserver/handler-api.go @@ -13,12 +13,10 @@ import ( "strconv" "strings" "sync" - "time" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/model" - "github.com/gofrs/uuid" "github.com/julienschmidt/httprouter" "golang.org/x/crypto/bcrypt" ) @@ -48,99 +46,6 @@ func downloadBookmarkContent(book *model.Bookmark, dataDir string, request *http return &result, err } -// apiLogin is handler for POST /api/login -func (h *Handler) apiLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - ctx := r.Context() - - // Decode request - request := struct { - Username string `json:"username"` - Password string `json:"password"` - Remember bool `json:"remember"` - Owner bool `json:"owner"` - }{} - - err := json.NewDecoder(r.Body).Decode(&request) - checkError(err) - - // Prepare function to generate session - genSession := func(account model.Account, expTime time.Duration) { - // Create session ID - sessionID, err := uuid.NewV4() - checkError(err) - - // Save session ID to cache - strSessionID := sessionID.String() - h.SessionCache.Set(strSessionID, account, expTime) - - // Save user's session IDs to cache as well - // useful for mass logout - sessionIDs := []string{strSessionID} - if val, found := h.UserCache.Get(request.Username); found { - sessionIDs = val.([]string) - sessionIDs = append(sessionIDs, strSessionID) - } - h.UserCache.Set(request.Username, sessionIDs, -1) - - // Send login result - account.Password = "" - loginResult := struct { - Session string `json:"session"` - Account model.Account `json:"account"` - Expires string `json:"expires"` - }{strSessionID, account, time.Now().UTC().Add(expTime).Format(time.RFC1123)} - - w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(&loginResult) - checkError(err) - } - - // Check if user's database is empty or there are no owner. - // If yes, and user uses default account, let him in. - searchOptions := database.GetAccountsOptions{ - Owner: true, - } - - accounts, err := h.DB.GetAccounts(ctx, searchOptions) - checkError(err) - - if len(accounts) == 0 && request.Username == "shiori" && request.Password == "gopher" { - genSession(model.Account{ - Username: "shiori", - Owner: true, - }, time.Hour) - return - } - - // Get account data from database - account, exist, err := h.DB.GetAccount(ctx, request.Username) - checkError(err) - - if !exist { - panic(fmt.Errorf("username doesn't exist")) - } - - // Compare password with database - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(request.Password)) - if err != nil { - panic(fmt.Errorf("username and password don't match")) - } - - // If login request is as owner, make sure this account is owner - if request.Owner && !account.Owner { - panic(fmt.Errorf("account level is not sufficient as owner")) - } - - // Calculate expiration time - expTime := time.Hour - if request.Remember { - expTime = time.Hour * 24 * 30 - } - - // Create session - genSession(account, expTime) -} - // ApiLogout is handler for POST /api/logout func (h *Handler) ApiLogout(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { // Get session ID diff --git a/internal/webserver/handler-ui.go b/internal/webserver/handler-ui.go index ca95434..2f300ae 100644 --- a/internal/webserver/handler-ui.go +++ b/internal/webserver/handler-ui.go @@ -20,76 +20,6 @@ import ( "github.com/go-shiori/shiori/internal/model" ) -// serveFile is handler for general file request -func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rootPath := strings.Trim(h.RootPath, "/") - urlPath := strings.Trim(r.URL.Path, "/") - filePath := strings.TrimPrefix(urlPath, rootPath) - filePath = strings.Trim(filePath, "/") - - err := serveFile(w, filePath, true) - checkError(err) -} - -// serveJsFile is handler for GET /js/*filepath -func (h *Handler) serveJsFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - jsFilePath := ps.ByName("filepath") - jsFilePath = path.Join("js", jsFilePath) - jsDir, jsName := path.Split(jsFilePath) - - if developmentMode && fp.Ext(jsName) == ".js" && strings.HasSuffix(jsName, ".min.js") { - jsName = strings.TrimSuffix(jsName, ".min.js") + ".js" - tmpPath := path.Join(jsDir, jsName) - if assetExists(tmpPath) { - jsFilePath = tmpPath - } - } - - err := serveFile(w, jsFilePath, true) - checkError(err) -} - -// serveIndexPage is handler for GET / -func (h *Handler) serveIndexPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - // Make sure session still valid - err := h.validateSession(r) - if err != nil { - newPath := path.Join(h.RootPath, "/login") - redirectURL := createRedirectURL(newPath, r.URL.String()) - redirectPage(w, r, redirectURL) - return - } - - if developmentMode { - if err := h.PrepareTemplates(); err != nil { - log.Printf("error during template preparation: %s", err) - } - } - - err = h.templates["index"].Execute(w, h.RootPath) - checkError(err) -} - -// serveLoginPage is handler for GET /login -func (h *Handler) serveLoginPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - // Make sure session is not valid - err := h.validateSession(r) - if err == nil { - redirectURL := path.Join(h.RootPath, "/") - redirectPage(w, r, redirectURL) - return - } - - if developmentMode { - if err := h.PrepareTemplates(); err != nil { - log.Printf("error during template preparation: %s", err) - } - } - - err = h.templates["login"].Execute(w, h.RootPath) - checkError(err) -} - // ServeBookmarkContent is handler for GET /bookmark/:id/content func (h *Handler) ServeBookmarkContent(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { ctx := r.Context() diff --git a/internal/webserver/handler.go b/internal/webserver/handler.go index 4b996d5..442466e 100644 --- a/internal/webserver/handler.go +++ b/internal/webserver/handler.go @@ -4,11 +4,13 @@ import ( "fmt" "html/template" "net/http" + "strings" + "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/model" - "github.com/go-shiori/warc" cch "github.com/patrickmn/go-cache" + "github.com/sirupsen/logrus" ) var developmentMode = false @@ -23,6 +25,8 @@ type Handler struct { ArchiveCache *cch.Cache Log bool + depenencies *config.Dependencies + templates map[string]*template.Template } @@ -46,13 +50,6 @@ func (h *Handler) PrepareSessionCache() { }) } -func (h *Handler) prepareArchiveCache() { - h.ArchiveCache.OnEvicted(func(key string, data interface{}) { - archive := data.(*warc.Archive) - archive.Close() - }) -} - func (h *Handler) PrepareTemplates() error { // Prepare variables var err error @@ -109,6 +106,31 @@ func (h *Handler) GetSessionID(r *http.Request) string { // validateSession checks whether user session is still valid or not func (h *Handler) validateSession(r *http.Request) error { + authorization := r.Header.Get(model.AuthorizationHeader) + if authorization != "" { + authParts := strings.SplitN(authorization, " ", 2) + if len(authParts) != 2 && authParts[0] != model.AuthorizationTokenType { + return fmt.Errorf("session has been expired") + } + + account, err := h.depenencies.Domains.Auth.CheckToken(r.Context(), authParts[1]) + if err != nil { + return err + } + + if r.Method != "" && r.Method != "GET" && !account.Owner { + return fmt.Errorf("account level is not sufficient") + } + + h.depenencies.Log.WithFields(logrus.Fields{ + "username": account.Username, + "method": r.Method, + "path": r.URL.Path, + }).Info("allowing legacy api access using JWT token") + + return nil + } + sessionID := h.GetSessionID(r) if sessionID == "" { return fmt.Errorf("session is not exist") diff --git a/internal/webserver/server.go b/internal/webserver/server.go index aaf567e..593255d 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -1,15 +1,11 @@ package webserver import ( - "fmt" - "net/http" - "path" "time" + "github.com/go-shiori/shiori/internal/config" "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 @@ -22,89 +18,7 @@ type Config struct { 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 -} - -// Logger 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": GetUserRealIP(r), - "reqlen": r.ContentLength, - "size": size, - "status": statusCode, - }).Info(r.Method, " ", r.RequestURI) - } else { - logrus.WithFields(logrus.Fields{ - "proto": r.Proto, - "remote": GetUserRealIP(r), - "reqlen": r.ContentLength, - "size": size, - "status": statusCode, - }).Warn(r.Method, " ", r.RequestURI) - } -} - -func GetLegacyHandler(cfg Config) *Handler { +func GetLegacyHandler(cfg Config, dependencies *config.Dependencies) *Handler { return &Handler{ DB: cfg.DB, DataDir: cfg.DataDir, @@ -113,128 +27,6 @@ func GetLegacyHandler(cfg Config) *Handler { ArchiveCache: cch.New(time.Minute, 5*time.Minute), RootPath: cfg.RootPath, Log: cfg.Log, + depenencies: dependencies, } } - -// ServeApp serves web interface in specified port -func ServeApp(cfg Config) error { - // Create handler - hdl := GetLegacyHandler(cfg) - - 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/ebook"), withLogging(hdl.ServeBookmarkEbook)) - 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/ebook"), withLogging(hdl.ApiDownloadEbook)) - 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() -} diff --git a/internal/webserver/utils.go b/internal/webserver/utils.go index 08086fa..c4e69d9 100644 --- a/internal/webserver/utils.go +++ b/internal/webserver/utils.go @@ -1,75 +1,18 @@ package webserver import ( - "fmt" "html/template" "io" - "mime" "net" "net/http" nurl "net/url" "os" - fp "path/filepath" "regexp" "strings" "syscall" ) -var ( - rxRepeatedStrip = regexp.MustCompile(`(?i)-+`) - - presetMimeTypes = map[string]string{ - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".js": "application/javascript", - ".png": "image/png", - } -) - -func guessTypeByExtension(ext string) string { - ext = strings.ToLower(ext) - - if v, ok := presetMimeTypes[ext]; ok { - return v - } - - return mime.TypeByExtension(ext) -} - -func serveFile(w http.ResponseWriter, filePath string, cache bool) error { - // Open file - src, err := assets.Open(filePath) - if err != nil { - return err - } - defer src.Close() - - // Cache this file if needed - if cache { - info, err := src.Stat() - if err != nil { - return err - } - - etag := fmt.Sprintf(`W/"%x-%x"`, info.ModTime().Unix(), info.Size()) - w.Header().Set("ETag", etag) - w.Header().Set("Cache-Control", "max-age=86400") - } else { - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - } - - // Set content type - ext := fp.Ext(filePath) - mimeType := guessTypeByExtension(ext) - if mimeType != "" { - w.Header().Set("Content-Type", mimeType) - w.Header().Set("X-Content-Type-Options", "nosniff") - } - - // Serve file - _, err = io.Copy(w, src) - return err -} +var rxRepeatedStrip = regexp.MustCompile(`(?i)-+`) func createRedirectURL(newPath, previousPath string) string { urlQueries := nurl.Values{} @@ -87,14 +30,6 @@ func redirectPage(w http.ResponseWriter, r *http.Request, url string) { http.Redirect(w, r, url, http.StatusMovedPermanently) } -func assetExists(filePath string) bool { - f, err := assets.Open(filePath) - if f != nil { - f.Close() - } - return err == nil || !os.IsNotExist(err) -} - func fileExists(filePath string) bool { info, err := os.Stat(filePath) return err == nil && !info.IsDir()