Merge branch 'master' into fmartingr/issue455

This commit is contained in:
Felipe Martin 2023-07-21 07:57:50 +02:00 committed by GitHub
commit 464fd156f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 47 additions and 466 deletions

View file

@ -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 == "" {

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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()
}

View file

@ -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()