shiori/internal/webserver/server.go
Monirzadeh 249f4b89c8
Initial Ebook Support (#623)
* generate ebook

* serve ebook file

* Update ebook.go not download same image twice anymore

* download ebook request api/ui part

* fix typo

* add stylesheet

* update hasEbook status

* download link update after ebook generate

update bookmark command in ui

* download ebook with bookmark title

* Apply suggestions from code review for better error handling

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>

* Update internal/view/js/page/home.js fix typo

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>

* import error lib and retuen missing error

* move ebook download action to update cache

* replace io/ioutil with io

* add missing error handling

* update Archive now always update ebook

* replace panic error with 404

* remove ebook with delete action

* add download ebook link to content page

* remove tags that not work correctly right now

* if file is pdf not generate ebook

* update style.css

* Revert "update style.css"

This reverts commit 519e10d6ce.

* remove download limit for api

* fix missing fmt.Errorf and change to errors.Wrap

* fix double panic

* return 404 if bookmark not exist

* change function name to GenerateEbook

* not isFatalErr anymore

* add unit test

* remove uneeded field for unit test

---------

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>
2023-07-09 08:29:32 +02:00

236 lines
7 KiB
Go

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