diff --git a/README.md b/README.md index 70f65e3b..22efc174 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A personal documents server, Coreander indexes the documents (EPUBs and PDFs wit * [Send to email supported](#send-to-email). * Read indexed epubs and PDFs from Coreander's interface thanks to [epub.js](http://futurepress.org/) and [pdf.js](https://mozilla.github.io/pdf.js/). * Restrictable access only to registered users. +* Upload documents through the web interface. ## Installation @@ -79,7 +80,7 @@ Coreander can send documents through email. This way, you can take advantage of ### User management and access restriction -Coreander distinguish between two kinds of users: regular users and administrator users, with the latter being the only ones with the ability to create new users and delete documents. +Coreander distinguish between two kinds of users: regular users and administrator users, with the latter being the only ones with the ability to create new users and upload and delete documents. By default, Coreander allow unrestricted access to its contents, except management areas which require an administrator user. To allow access only to registered users in the whole application, pass the `REQUIRE_AUTH=true` environment variable. @@ -106,3 +107,5 @@ On first run, Coreander creates an admin user with the following credentials: * `MIN_PASSWORD_LENGTH`: Minimum length acceptable for passwords. Defaults to 5. * `WORDS_PER_MINUTE`: Defines a default words per minute reading speed that will be used for not logged-in users. Defaults to 250. * `SESSION_TIMEOUT`: Specifies the maximum time a user session may last, in hours. Floating-point values are allowed. Defaults to 24 hours. +* `UPLOAD_DOCUMENT_MAX_SIZE`: Maximum document size allowed to be uploaded to the library, in megabytes. Set this to 0 to unlimit upload size. Defaults to 20 megabytes. + diff --git a/config.go b/config.go index 06ccb701..5633e580 100644 --- a/config.go +++ b/config.go @@ -32,4 +32,7 @@ type Config struct { WordsPerMinute float64 `env:"WORDS_PER_MINUTE" env-default:"250"` // SessionTimeout specifies the maximum time a user session may last in hours SessionTimeout float64 `env:"SESSION_TIMEOUT" env-default:"24"` + // UploadDocumentMaxSize is the maximum document size allowed to be uploaded to the library, in megabytes. + // Set this to 0 to unlimit upload size. Defaults to 20 megabytes. + UploadDocumentMaxSize int `env:"UPLOAD_DOCUMENT_MAX_SIZE" env-default:"20"` } diff --git a/internal/index/bleve_write.go b/internal/index/bleve_write.go index 11698490..c6c4cec1 100644 --- a/internal/index/bleve_write.go +++ b/internal/index/bleve_write.go @@ -17,7 +17,7 @@ import ( func (b *BleveIndexer) AddFile(file string) error { ext := strings.ToLower(filepath.Ext(file)) if _, ok := b.reader[ext]; !ok { - return nil + return fmt.Errorf("file extension %s not supported", ext) } meta, err := b.reader[ext].Metadata(file) if err != nil { diff --git a/internal/metadata/epub.go b/internal/metadata/epub.go index a1ad04a7..d8f917d0 100644 --- a/internal/metadata/epub.go +++ b/internal/metadata/epub.go @@ -170,7 +170,7 @@ func words(documentFullPath string) (int, error) { defer r.Close() count := 0 for _, f := range r.File { - isContent, err := doublestar.PathMatch("O*PS/**/*.*html", f.Name) + isContent, err := doublestar.PathMatch("O*PS/**/*.*htm*", f.Name) if err != nil { return 0, err } diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go index 09587f39..22392702 100644 --- a/internal/webserver/controller.go +++ b/internal/webserver/controller.go @@ -49,10 +49,13 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada } documentsCfg := document.Config{ - WordsPerMinute: cfg.WordsPerMinute, - LibraryPath: cfg.LibraryPath, - HomeDir: cfg.HomeDir, - CoverMaxWidth: cfg.CoverMaxWidth, + WordsPerMinute: cfg.WordsPerMinute, + LibraryPath: cfg.LibraryPath, + HomeDir: cfg.HomeDir, + CoverMaxWidth: cfg.CoverMaxWidth, + Hostname: cfg.Hostname, + Port: cfg.Port, + UploadDocumentMaxSize: cfg.UploadDocumentMaxSize, } authController := auth.NewController(usersRepository, sender, authCfg, printers) @@ -97,6 +100,10 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada SigningKey: cfg.JwtSecret, SigningMethod: "HS256", TokenLookup: "cookie:coreander", + SuccessHandler: func(c *fiber.Ctx) error { + c.Locals("Session", jwtclaimsreader.SessionData(c)) + return c.Next() + }, ErrorHandler: func(c *fiber.Ctx, err error) error { return forbidden(c) }, @@ -105,6 +112,10 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada SigningKey: cfg.JwtSecret, SigningMethod: "HS256", TokenLookup: "cookie:coreander", + SuccessHandler: func(c *fiber.Ctx) error { + c.Locals("Session", jwtclaimsreader.SessionData(c)) + return c.Next() + }, ErrorHandler: func(c *fiber.Ctx, err error) error { err = c.Next() if cfg.RequireAuth { @@ -116,7 +127,6 @@ func SetupControllers(cfg Config, db *gorm.DB, metadataReaders map[string]metada ErrorHandler: func(c *fiber.Ctx, err error) error { // Status code defaults to 500 code := fiber.StatusInternalServerError - // Retrieve the custom status code if it's a *fiber.Error var e *fiber.Error if errors.As(err, &e) { diff --git a/internal/webserver/controller/auth/controller.go b/internal/webserver/controller/auth/controller.go index 7ff35292..1705a8d7 100644 --- a/internal/webserver/controller/auth/controller.go +++ b/internal/webserver/controller/auth/controller.go @@ -1,10 +1,8 @@ package auth import ( - "fmt" "time" - "github.com/gofiber/fiber/v2" "github.com/svera/coreander/v3/internal/webserver/model" "golang.org/x/text/message" ) @@ -34,11 +32,6 @@ type Config struct { SessionTimeout time.Duration } -const ( - defaultHttpPort = 80 - defaultHttpsPort = 443 -) - func NewController(repository authRepository, sender recoveryEmail, cfg Config, printers map[string]*message.Printer) *Controller { return &Controller{ repository: repository, @@ -47,12 +40,3 @@ func NewController(repository authRepository, sender recoveryEmail, cfg Config, config: cfg, } } - -func (a *Controller) urlPort(c *fiber.Ctx) string { - port := fmt.Sprintf(":%d", a.config.Port) - if (a.config.Port == defaultHttpPort && c.Protocol() == "http") || - (a.config.Port == defaultHttpsPort && c.Protocol() == "https") { - port = "" - } - return port -} diff --git a/internal/webserver/controller/auth/login.go b/internal/webserver/controller/auth/login.go index a1ae5e2c..ccbff48e 100644 --- a/internal/webserver/controller/auth/login.go +++ b/internal/webserver/controller/auth/login.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" + "github.com/svera/coreander/v3/internal/webserver/controller" "github.com/svera/coreander/v3/internal/webserver/infrastructure" ) @@ -13,7 +14,7 @@ func (a *Controller) Login(c *fiber.Ctx) error { "%s://%s%s/%s/reset-password", c.Protocol(), a.config.Hostname, - a.urlPort(c), + controller.UrlPort(c.Protocol(), a.config.Port), c.Params("lang"), ) diff --git a/internal/webserver/controller/auth/request.go b/internal/webserver/controller/auth/request.go index c60a08d3..b8661a1b 100644 --- a/internal/webserver/controller/auth/request.go +++ b/internal/webserver/controller/auth/request.go @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" + "github.com/svera/coreander/v3/internal/webserver/controller" "github.com/svera/coreander/v3/internal/webserver/infrastructure" ) @@ -33,7 +34,7 @@ func (a *Controller) Request(c *fiber.Ctx) error { "%s://%s%s/%s/reset-password?id=%s", c.Protocol(), a.config.Hostname, - a.urlPort(c), + controller.UrlPort(c.Protocol(), a.config.Port), c.Params("lang"), user.RecoveryUUID, ) diff --git a/internal/webserver/controller/controller.go b/internal/webserver/controller/controller.go new file mode 100644 index 00000000..8338f241 --- /dev/null +++ b/internal/webserver/controller/controller.go @@ -0,0 +1,17 @@ +package controller + +import "fmt" + +const ( + defaultHttpPort = 80 + defaultHttpsPort = 443 +) + +func UrlPort(protocol string, port int) string { + urlPort := fmt.Sprintf(":%d", port) + if (port == defaultHttpPort && protocol == "http") || + (port == defaultHttpsPort && protocol == "https") { + urlPort = "" + } + return urlPort +} diff --git a/internal/webserver/controller/document/controller.go b/internal/webserver/controller/document/controller.go index acb58656..3a3620d6 100644 --- a/internal/webserver/controller/document/controller.go +++ b/internal/webserver/controller/document/controller.go @@ -23,6 +23,7 @@ type IdxReaderWriter interface { SameSubjects(slug string, quantity int) ([]index.Document, error) SameAuthors(slug string, quantity int) ([]index.Document, error) SameSeries(slug string, quantity int) ([]index.Document, error) + AddFile(file string) error RemoveFile(file string) error Documents(IDs []string) (map[string]index.Document, error) } @@ -34,10 +35,13 @@ type highlightsRepository interface { } type Config struct { - WordsPerMinute float64 - LibraryPath string - HomeDir string - CoverMaxWidth int + WordsPerMinute float64 + LibraryPath string + HomeDir string + CoverMaxWidth int + Hostname string + Port int + UploadDocumentMaxSize int } type Controller struct { diff --git a/internal/webserver/controller/document/detail.go b/internal/webserver/controller/document/detail.go index 46f89f55..8e54e3d2 100644 --- a/internal/webserver/controller/document/detail.go +++ b/internal/webserver/controller/document/detail.go @@ -8,7 +8,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" + "github.com/svera/coreander/v3/internal/webserver/model" ) func (d *Controller) Detail(c *fiber.Ctx) error { @@ -17,7 +17,11 @@ func (d *Controller) Detail(c *fiber.Ctx) error { emailSendingConfigured = false } - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } + if session.WordsPerMinute > 0 { d.config.WordsPerMinute = session.WordsPerMinute } @@ -61,7 +65,6 @@ func (d *Controller) Detail(c *fiber.Ctx) error { "Document": document, "EmailSendingConfigured": emailSendingConfigured, "EmailFrom": d.sender.From(), - "Session": session, "SameSeries": sameSeries, "SameAuthors": sameAuthors, "SameSubjects": sameSubjects, diff --git a/internal/webserver/controller/document/search.go b/internal/webserver/controller/document/search.go index 4d0bac0c..a308a1fc 100644 --- a/internal/webserver/controller/document/search.go +++ b/internal/webserver/controller/document/search.go @@ -7,7 +7,6 @@ import ( "github.com/svera/coreander/v3/internal/index" "github.com/svera/coreander/v3/internal/result" "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" "github.com/svera/coreander/v3/internal/webserver/view" ) @@ -23,7 +22,11 @@ func (d *Controller) Search(c *fiber.Ctx) error { page = 1 } - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } + if session.WordsPerMinute > 0 { d.config.WordsPerMinute = session.WordsPerMinute } @@ -46,7 +49,6 @@ func (d *Controller) Search(c *fiber.Ctx) error { "Title": "Search results", "EmailSendingConfigured": emailSendingConfigured, "EmailFrom": d.sender.From(), - "Session": session, "WordsPerMinute": d.config.WordsPerMinute, }, "layout") } @@ -77,7 +79,6 @@ func (d *Controller) Search(c *fiber.Ctx) error { return c.Render("index", fiber.Map{ "Count": count, "Title": "Coreander", - "Session": session, "Highlights": docsSortedByHighlightedDate, "EmailSendingConfigured": emailSendingConfigured, "EmailFrom": d.sender.From(), diff --git a/internal/webserver/controller/document/upload.go b/internal/webserver/controller/document/upload.go new file mode 100644 index 00000000..f7c80a45 --- /dev/null +++ b/internal/webserver/controller/document/upload.go @@ -0,0 +1,124 @@ +package document + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/svera/coreander/v3/internal/webserver/controller" + "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/valyala/fasthttp" +) + +func (d *Controller) UploadForm(c *fiber.Ctx) error { + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } + + if session.Role != model.RoleAdmin { + return fiber.ErrForbidden + } + + upload := fmt.Sprintf( + "%s://%s%s/%s/upload", + c.Protocol(), + d.config.Hostname, + controller.UrlPort(c.Protocol(), d.config.Port), + c.Params("lang"), + ) + + msg := "" + if ref := string(c.Request().Header.Referer()); strings.HasPrefix(ref, upload) { + msg = "Document uploaded successfully." + } + + return c.Render("upload", fiber.Map{ + "Title": "Coreander", + "Message": msg, + "MaxSize": d.config.UploadDocumentMaxSize, + }, "layout") +} + +func (d *Controller) Upload(c *fiber.Ctx) error { + session := c.Locals("Session").(model.User) + + if session.Role != model.RoleAdmin { + return fiber.ErrForbidden + } + + file, err := c.FormFile("filename") + if err != nil { + if errors.Is(err, fasthttp.ErrMissingFile) { + return c.Status(fiber.StatusBadRequest).Render("upload", fiber.Map{ + "Title": "Coreander", + "Error": "Invalid file type", + }, "layout") + } + } + + allowedTypes := []string{"application/epub+zip", "application/pdf"} + if !slices.Contains(allowedTypes, file.Header.Get("Content-Type")) { + return c.Status(fiber.StatusBadRequest).Render("upload", fiber.Map{ + "Title": "Coreander", + "Error": "Invalid file type", + }, "layout") + } + + if file.Size > int64(d.config.UploadDocumentMaxSize*1024*1024) { + return c.Status(fiber.StatusRequestEntityTooLarge).Render("upload", fiber.Map{ + "Title": "Coreander", + "Error": fmt.Sprintf("Document too large, the maximum allowed size is %d megabytes", d.config.UploadDocumentMaxSize), + }, "layout") + } + + destination := filepath.Join(d.config.LibraryPath, file.Filename) + internalServerErrorStatus := c.Status(fiber.StatusInternalServerError).Render("upload", fiber.Map{ + "Title": "Coreander", + "Error": "Error uploading document", + }, "layout") + + bytes, err := fileToBytes(file) + if err != nil { + return internalServerErrorStatus + } + + destFile, err := d.appFs.Create(destination) + if err != nil { + return internalServerErrorStatus + } + + if _, err := destFile.Write(bytes); err != nil { + return internalServerErrorStatus + } + + destFile.Close() + if err := d.idx.AddFile(destination); err != nil { + os.Remove(destination) + return internalServerErrorStatus + } + + return c.Redirect(fmt.Sprintf("/%s/upload", c.Params("lang"))) +} + +func fileToBytes(fileHeader *multipart.FileHeader) ([]byte, error) { + f, err := fileHeader.Open() + if err != nil { + return []byte{}, err + } + defer f.Close() + + buf := bytes.NewBuffer(nil) + if _, err := io.Copy(buf, f); err != nil { + return []byte{}, err + } + + return buf.Bytes(), nil +} diff --git a/internal/webserver/controller/highlight/highlights.go b/internal/webserver/controller/highlight/highlights.go index 1a958a75..66a48680 100644 --- a/internal/webserver/controller/highlight/highlights.go +++ b/internal/webserver/controller/highlight/highlights.go @@ -7,7 +7,6 @@ import ( "github.com/svera/coreander/v3/internal/index" "github.com/svera/coreander/v3/internal/result" "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" "github.com/svera/coreander/v3/internal/webserver/view" ) @@ -23,7 +22,11 @@ func (h *Controller) Highlights(c *fiber.Ctx) error { page = 1 } - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } + if session.WordsPerMinute > 0 { h.wordsPerMinute = session.WordsPerMinute } @@ -66,7 +69,6 @@ func (h *Controller) Highlights(c *fiber.Ctx) error { "Title": "Highlights", "EmailSendingConfigured": emailSendingConfigured, "EmailFrom": h.sender.From(), - "Session": session, "WordsPerMinute": h.wordsPerMinute, }, "layout") } diff --git a/internal/webserver/controller/user/controller.go b/internal/webserver/controller/user/controller.go index 4456c3b8..35cd13d5 100644 --- a/internal/webserver/controller/user/controller.go +++ b/internal/webserver/controller/user/controller.go @@ -3,7 +3,6 @@ package user import ( "github.com/gofiber/fiber/v2" "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" ) @@ -38,7 +37,10 @@ func NewController(repository usersRepository, usersCfg Config) *Controller { // New renders the new user form func (u *Controller) New(c *fiber.Ctx) error { - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } if session.Role != model.RoleAdmin { return fiber.ErrForbidden @@ -49,7 +51,6 @@ func (u *Controller) New(c *fiber.Ctx) error { } return c.Render("users/new", fiber.Map{ "Title": "Add user", - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "User": user, "Errors": map[string]string{}, diff --git a/internal/webserver/controller/user/create.go b/internal/webserver/controller/user/create.go index a105db1c..090b0957 100644 --- a/internal/webserver/controller/user/create.go +++ b/internal/webserver/controller/user/create.go @@ -6,13 +6,15 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" ) // Create gathers information coming from the new user form and creates a new user func (u *Controller) Create(c *fiber.Ctx) error { - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } if session.Role != model.RoleAdmin { return fiber.ErrForbidden @@ -35,10 +37,9 @@ func (u *Controller) Create(c *fiber.Ctx) error { if errs = user.ConfirmPassword(c.FormValue("confirm-password"), u.config.MinPasswordLength, errs); len(errs) > 0 { return c.Render("users/new", fiber.Map{ - "Title": "Add user", - "Session": session, - "Errors": errs, - "User": user, + "Title": "Add user", + "Errors": errs, + "User": user, }, "layout") } diff --git a/internal/webserver/controller/user/edit.go b/internal/webserver/controller/user/edit.go index da9dbc3f..9c2f26a9 100644 --- a/internal/webserver/controller/user/edit.go +++ b/internal/webserver/controller/user/edit.go @@ -2,7 +2,6 @@ package user import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" ) @@ -13,7 +12,10 @@ func (u *Controller) Edit(c *fiber.Ctx) error { return fiber.ErrNotFound } - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } if session.Role != model.RoleAdmin && session.Uuid != c.Params("uuid") { return fiber.ErrForbidden @@ -22,7 +24,6 @@ func (u *Controller) Edit(c *fiber.Ctx) error { return c.Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "Errors": map[string]string{}, }, "layout") diff --git a/internal/webserver/controller/user/list.go b/internal/webserver/controller/user/list.go index 27182263..b76c103e 100644 --- a/internal/webserver/controller/user/list.go +++ b/internal/webserver/controller/user/list.go @@ -4,14 +4,16 @@ import ( "strconv" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" "github.com/svera/coreander/v3/internal/webserver/view" ) // List list all users registered in the database func (u *Controller) List(c *fiber.Ctx) error { - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } if session.Role != model.RoleAdmin { return fiber.ErrForbidden @@ -27,7 +29,6 @@ func (u *Controller) List(c *fiber.Ctx) error { "Title": "Users", "Users": users.Hits(), "Paginator": view.Pagination(model.MaxPagesNavigator, users, map[string]string{}), - "Session": session, "Admins": u.repository.Admins(), }, "layout") } diff --git a/internal/webserver/controller/user/update.go b/internal/webserver/controller/user/update.go index 4a5c30ad..ca8767c9 100644 --- a/internal/webserver/controller/user/update.go +++ b/internal/webserver/controller/user/update.go @@ -4,7 +4,6 @@ import ( "strconv" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/jwtclaimsreader" "github.com/svera/coreander/v3/internal/webserver/model" ) @@ -15,7 +14,10 @@ func (u *Controller) Update(c *fiber.Ctx) error { return fiber.ErrNotFound } - session := jwtclaimsreader.SessionData(c) + var session model.User + if val, ok := c.Locals("Session").(model.User); ok { + session = val + } if session.Role != model.RoleAdmin && session.Uuid != c.Params("uuid") { return fiber.ErrForbidden @@ -34,7 +36,6 @@ func (u *Controller) Update(c *fiber.Ctx) error { return c.Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "Errors": errs, }, "layout") @@ -47,7 +48,6 @@ func (u *Controller) Update(c *fiber.Ctx) error { return c.Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "Errors": errs, "Message": "Profile updated", @@ -76,7 +76,6 @@ func (u *Controller) updatePassword(c *fiber.Ctx, session, user model.User) erro return c.Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "ActiveTab": "password", "Errors": errs, @@ -91,7 +90,6 @@ func (u *Controller) updatePassword(c *fiber.Ctx, session, user model.User) erro return c.Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, - "Session": session, "MinPasswordLength": u.config.MinPasswordLength, "ActiveTab": "password", "Errors": errs, diff --git a/internal/webserver/embedded/js/upload.js b/internal/webserver/embedded/js/upload.js new file mode 100644 index 00000000..3bfff7d2 --- /dev/null +++ b/internal/webserver/embedded/js/upload.js @@ -0,0 +1,29 @@ + const fileSelector = document.getElementById('file-selector'); + const uploadForm = document.getElementById('upload-form'); + let fileSubmit = document.getElementById('file-submit'); + + fileSelector.addEventListener('change', (event) => { + const fileList = Array.from(event.target.files); + let fileSelector = document.getElementById('file-selector'); + let errorMessageContainer = document.getElementsByClassName('invalid-feedback')[0]; + + fileList.forEach(element => { + if (element.size > fileSelector.dataset.max_size * 1024 * 1024) { + fileSubmit.setAttribute('disabled', ''); + fileSelector.classList.add('is-invalid'); + errorMessageContainer.classList.remove('visually-hidden'); + errorMessageContainer.textContent = fileSelector.dataset.error_too_large; + } else { + fileSubmit.removeAttribute('disabled'); + fileSelector.classList.remove('is-invalid'); + errorMessageContainer.classList.add('visually-hidden'); + errorMessageContainer.textContent = ''; + } + }); + }); + + uploadForm.addEventListener('submit', (event) => { + let spinner = document.querySelector('.spinner-border'); + spinner.classList.remove('visually-hidden') + fileSubmit.setAttribute('disabled', ''); + }); diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 5c81af78..ba1d781d 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -108,3 +108,9 @@ "Method not allowed": "Método no permitido" "Your highlights": "Tus destacados" "You haven't highlighted any document yet.": "Aún no has destacado ningún documento." +"Upload document": "Subir documento" +"Document uploaded successfully": "Documento subido con éxito." +"Document too large, the maximum allowed size is %d megabytes": "Documento demasiado grande, el tamaño máximo permitido es de %d megabytes" +"Request entity too large": "Petición demasiado grande" +"Error uploading document": "Error al subir el documento" +"Invalid file type": "Tipo de archivo no válido" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index c7fdc8c4..766b7d05 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -108,3 +108,9 @@ "Method not allowed": "Méthode Non Autorisée" "Your highlights": "Vos favoris" "You haven't highlighted any document yet.": "Vous n'avez encore ajouté aucun document à vos favoris." +"Upload document": "Télécharger document" +"Document uploaded successfully": "Document téléchargé avec succès." +"Document too large, the maximum allowed size is %d megabytes": "Document trop volumineux, la taille maximale autorisée est de %d mégaoctets" +"Request entity too large": "Entité de requête trop grande" +"Error uploading document": "Erreur lors du téléchargement du document" +"Invalid file type": "Type de fichier invalide" diff --git a/internal/webserver/embedded/views/errors/413.html b/internal/webserver/embedded/views/errors/413.html new file mode 100644 index 00000000..1dba9b0e --- /dev/null +++ b/internal/webserver/embedded/views/errors/413.html @@ -0,0 +1,3 @@ +
+

{{t .Lang "Request entity too large"}}

+
diff --git a/internal/webserver/embedded/views/highlights.html b/internal/webserver/embedded/views/highlights.html index 2bc53bc7..fe6e8bb8 100644 --- a/internal/webserver/embedded/views/highlights.html +++ b/internal/webserver/embedded/views/highlights.html @@ -8,8 +8,7 @@

{{t .Lang "%s highlights" .Session.Name}}

{{end}} - {{ template "partials/docs-list" dict "Lang" .Lang "EmailSendingConfigured" - .EmailSendingConfigured "Session" .Session "EmailFrom" .EmailFrom "Results" .Results "WordsPerMinute" .WordsPerMinute}} + {{ template "partials/docs-list" .}} {{ $length := len .Paginator.Pages }} {{ if gt $length 1 }} {{template "partials/pagination" .}} diff --git a/internal/webserver/embedded/views/layout.html b/internal/webserver/embedded/views/layout.html index 5105e275..6ea5f5ed 100644 --- a/internal/webserver/embedded/views/layout.html +++ b/internal/webserver/embedded/views/layout.html @@ -79,7 +79,17 @@
Coreander
{{t .Lang "Highlights"}} - + + {{if eq .Session.Role 2}} +
  • + + + + + {{t .Lang "Upload document"}} + +
  • + {{end}}
  • diff --git a/internal/webserver/embedded/views/partials/actions.html b/internal/webserver/embedded/views/partials/actions.html index 144d06f6..41e018de 100644 --- a/internal/webserver/embedded/views/partials/actions.html +++ b/internal/webserver/embedded/views/partials/actions.html @@ -50,7 +50,7 @@
    + placeholder="email@example.com" value="{{if .Session}}{{.Session.SendToEmail}}{{end}}" required="required"> +
    +
    +
    + + + + + + diff --git a/internal/webserver/fixtures/empty.epub b/internal/webserver/fixtures/library/empty.epub similarity index 100% rename from internal/webserver/fixtures/empty.epub rename to internal/webserver/fixtures/library/empty.epub diff --git a/internal/webserver/fixtures/empty.pdf b/internal/webserver/fixtures/library/empty.pdf similarity index 100% rename from internal/webserver/fixtures/empty.pdf rename to internal/webserver/fixtures/library/empty.pdf diff --git a/internal/webserver/fixtures/metadata.epub b/internal/webserver/fixtures/library/metadata.epub similarity index 100% rename from internal/webserver/fixtures/metadata.epub rename to internal/webserver/fixtures/library/metadata.epub diff --git a/internal/webserver/fixtures/metadata.pdf b/internal/webserver/fixtures/library/metadata.pdf similarity index 100% rename from internal/webserver/fixtures/metadata.pdf rename to internal/webserver/fixtures/library/metadata.pdf diff --git a/internal/webserver/fixtures/metadata_uppercase_ext.PDF b/internal/webserver/fixtures/library/metadata_uppercase_ext.PDF similarity index 100% rename from internal/webserver/fixtures/metadata_uppercase_ext.PDF rename to internal/webserver/fixtures/library/metadata_uppercase_ext.PDF diff --git a/internal/webserver/fixtures/nested/other.epub b/internal/webserver/fixtures/library/nested/other.epub similarity index 100% rename from internal/webserver/fixtures/nested/other.epub rename to internal/webserver/fixtures/library/nested/other.epub diff --git a/internal/webserver/fixtures/quijote.epub b/internal/webserver/fixtures/library/quijote.epub similarity index 100% rename from internal/webserver/fixtures/quijote.epub rename to internal/webserver/fixtures/library/quijote.epub diff --git a/internal/webserver/fixtures/quijote_another_edition.epub b/internal/webserver/fixtures/library/quijote_another_edition.epub similarity index 100% rename from internal/webserver/fixtures/quijote_another_edition.epub rename to internal/webserver/fixtures/library/quijote_another_edition.epub diff --git a/internal/webserver/fixtures/upload/childrens-literature.epub b/internal/webserver/fixtures/upload/childrens-literature.epub new file mode 100644 index 00000000..ba84a643 Binary files /dev/null and b/internal/webserver/fixtures/upload/childrens-literature.epub differ diff --git a/internal/webserver/fixtures/upload/haruko-html-jpeg.epub b/internal/webserver/fixtures/upload/haruko-html-jpeg.epub new file mode 100644 index 00000000..f8251030 Binary files /dev/null and b/internal/webserver/fixtures/upload/haruko-html-jpeg.epub differ diff --git a/internal/webserver/highlights_test.go b/internal/webserver/highlights_test.go index 7f594d0c..be594e22 100644 --- a/internal/webserver/highlights_test.go +++ b/internal/webserver/highlights_test.go @@ -15,7 +15,7 @@ import ( func TestHighlights(t *testing.T) { db := infrastructure.Connect("file::memory:", 250) - appFS := loadFilesInMemoryFs([]string{"fixtures/metadata.epub"}) + appFS := loadFilesInMemoryFs([]string{"fixtures/library/metadata.epub"}) app := bootstrapApp(db, &infrastructure.NoEmail{}, appFS) data := url.Values{ "slug": {"john-doe-test-epub"}, @@ -37,7 +37,7 @@ func TestHighlights(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - response, err := addUser(regularUserData, adminCookie, app) + response, err := postRequest(regularUserData, adminCookie, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -98,7 +98,7 @@ func TestHighlights(t *testing.T) { "uuid": {regularUser.Uuid}, } - _, err = deleteUser(data, adminCookie, app) + _, err = postRequest(data, adminCookie, app, "/en/users/delete") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 2119fb5d..84cf1e13 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -58,6 +58,9 @@ func routes(app *fiber.App, controllers Controllers, supportedLanguages []string app.Post("/delete", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.Delete) + langGroup.Get("/upload", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.UploadForm) + langGroup.Post("/upload", controllers.AlwaysRequireAuthenticationMiddleware, controllers.Documents.Upload) + // Authentication requirement is configurable for all routes below this middleware app.Use(controllers.ConfigurableAuthenticationMiddleware) diff --git a/internal/webserver/search_test.go b/internal/webserver/search_test.go index b0185b44..82ed4411 100644 --- a/internal/webserver/search_test.go +++ b/internal/webserver/search_test.go @@ -19,7 +19,9 @@ import ( func TestSearch(t *testing.T) { db := infrastructure.Connect("file::memory:", 250) smtpMock := &SMTPMock{} - app := bootstrapApp(db, smtpMock, afero.NewOsFs()) + appFS := loadDirInMemoryFs("fixtures/library") + + app := bootstrapApp(db, smtpMock, appFS) var cases = []struct { name string @@ -113,7 +115,7 @@ func TestSendDocument(t *testing.T) { func TestRemoveDocument(t *testing.T) { db := infrastructure.Connect("file::memory:", 250) smtpMock := &SMTPMock{} - appFS := loadFilesInMemoryFs([]string{"fixtures/metadata.epub"}) + appFS := loadDirInMemoryFs("fixtures/library") app := bootstrapApp(db, smtpMock, appFS) assertSearchResults(app, t, "john+doe", 4) diff --git a/internal/webserver/upload_test.go b/internal/webserver/upload_test.go new file mode 100644 index 00000000..3c5bd335 --- /dev/null +++ b/internal/webserver/upload_test.go @@ -0,0 +1,226 @@ +package webserver_test + +import ( + "bytes" + "fmt" + "log" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/spf13/afero" + "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v3/internal/webserver/model" +) + +func TestUpload(t *testing.T) { + db := infrastructure.Connect("file::memory:", 250) + appFS := loadDirInMemoryFs("fixtures/library") + app := bootstrapApp(db, &infrastructure.NoEmail{}, appFS) + + data := url.Values{ + "name": {"Test user"}, + "email": {"test@example.com"}, + "password": {"test"}, + "confirm-password": {"test"}, + "role": {fmt.Sprint(model.RoleRegular)}, + "words-per-minute": {"250"}, + } + + adminCookie, err := login(app, "admin@example.com", "admin") + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + t.Run("Try to access upload page without an active session", func(t *testing.T) { + response, err := getRequest(&http.Cookie{}, app, "/en/upload") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + mustReturnForbiddenAndShowLogin(response, t) + }) + + t.Run("Try to access upload page with a regular user session", func(t *testing.T) { + response, err := postRequest(data, adminCookie, app, "/en/users/new") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + cookie, err := login(app, "test@example.com", "test") + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + response, err = getRequest(cookie, app, "/en/upload") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + mustReturnStatus(response, fiber.StatusForbidden, t) + }) + + t.Run("Try to access upload page with an admin active session", func(t *testing.T) { + response, err := getRequest(adminCookie, app, "/en/upload") + if response == nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + if expectedStatus := http.StatusOK; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + }) + + t.Run("Returns 400 for file content-type not allowed", func(t *testing.T) { + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + + // add form field + filePart, _ := multipartWriter.CreateFormFile("filename", "file.txt") + filePart.Write([]byte("Hello, World!")) + + multipartWriter.Close() + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(adminCookie) + + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + if expectedStatus := http.StatusBadRequest; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + }) + + t.Run("Returns 500 if a document was uploaded correctly but couldn't be indexed", func(t *testing.T) { + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "filename", "file.txt")) + h.Set("Content-Type", "application/epub+zip") + part, _ := multipartWriter.CreatePart(h) + part.Write([]byte(`sample`)) + multipartWriter.Close() + + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(adminCookie) + + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + if expectedStatus := http.StatusInternalServerError; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + }) + + t.Run("Returns 400 when trying to send no file", func(t *testing.T) { + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + multipartWriter.Close() + + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(adminCookie) + + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + if expectedStatus := http.StatusBadRequest; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + }) + + t.Run("Returns 413 for file too big", func(t *testing.T) { + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + + file, err := os.ReadFile("fixtures/upload/haruko-html-jpeg.epub") + if err != nil { + log.Fatal(err) + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "filename", "haruko-html-jpeg.epub")) + h.Set("Content-Type", "application/epub+zip") + part, _ := multipartWriter.CreatePart(h) + part.Write(file) + + multipartWriter.Close() + + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(adminCookie) + + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + if expectedStatus := http.StatusRequestEntityTooLarge; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + }) + + // Due to a limitation in how pirmd/epub handles opening epub files, we need to use + // a real filesystem instead Afero's in-memory implementatio + t.Run("Returns 302 for correct document", func(t *testing.T) { + app := bootstrapApp(db, &infrastructure.NoEmail{}, afero.NewOsFs()) + var buf bytes.Buffer + multipartWriter := multipart.NewWriter(&buf) + + file, err := os.ReadFile("fixtures/upload/childrens-literature.epub") + if err != nil { + log.Fatal(err) + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "filename", "childrens-literature.epub")) + h.Set("Content-Type", "application/epub+zip") + part, _ := multipartWriter.CreatePart(h) + part.Write(file) + + multipartWriter.Close() + + req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + req.AddCookie(adminCookie) + + response, err := app.Test(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err.Error()) + } + + if expectedStatus := http.StatusFound; response.StatusCode != expectedStatus { + t.Errorf("Expected status %d, got %d", expectedStatus, response.StatusCode) + } + + assertSearchResults(app, t, "children+literature", 1) + + os.Remove("fixtures/library/childrens-literature.epub") + }) +} diff --git a/internal/webserver/user_management_test.go b/internal/webserver/user_management_test.go index 69c47fdd..df2803f1 100644 --- a/internal/webserver/user_management_test.go +++ b/internal/webserver/user_management_test.go @@ -34,14 +34,14 @@ func TestUserManagement(t *testing.T) { } t.Run("Try to add a user without an active session", func(t *testing.T) { - response, err := newUser(&http.Cookie{}, app) + response, err := getRequest(&http.Cookie{}, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnForbiddenAndShowLogin(response, t) - response, err = addUser(data, &http.Cookie{}, app) + response, err = postRequest(data, &http.Cookie{}, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -50,14 +50,14 @@ func TestUserManagement(t *testing.T) { }) t.Run("Try to add a user with an admin active session", func(t *testing.T) { - response, err := newUser(adminCookie, app) + response, err := getRequest(adminCookie, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - response, err = addUser(data, adminCookie, app) + response, err = postRequest(data, adminCookie, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -72,7 +72,7 @@ func TestUserManagement(t *testing.T) { }) t.Run("Try to add a user with errors in form using an admin active session", func(t *testing.T) { - response, err := addUser(url.Values{}, adminCookie, app) + response, err := postRequest(url.Values{}, adminCookie, app, "/en/users/new") expectedErrorMessages := []string{ "Name cannot be empty", "Incorrect email address", @@ -103,7 +103,7 @@ func TestUserManagement(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - response, err := addUser(data, cookie, app) + response, err := postRequest(data, cookie, app, "/en/users/new") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -123,14 +123,14 @@ func TestUserManagement(t *testing.T) { } t.Run("Try to update a user without an active session", func(t *testing.T) { - response, err := editUser(testUser.Uuid, &http.Cookie{}, app) + response, err := getRequest(&http.Cookie{}, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnForbiddenAndShowLogin(response, t) - response, err = updateUser(testUser.Uuid, data, &http.Cookie{}, app) + response, err = postRequest(data, &http.Cookie{}, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -139,14 +139,14 @@ func TestUserManagement(t *testing.T) { }) t.Run("Try to update a user using another, non admin user session", func(t *testing.T) { - response, err := editUser(adminUser.Uuid, testUserCookie, app) + response, err := getRequest(testUserCookie, app, fmt.Sprintf("/en/users/%s/edit", adminUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusForbidden, t) - response, err = updateUser(adminUser.Uuid, data, testUserCookie, app) + response, err = postRequest(data, testUserCookie, app, fmt.Sprintf("/en/users/%s/edit", adminUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -157,14 +157,14 @@ func TestUserManagement(t *testing.T) { t.Run("Try to update the user in session", func(t *testing.T) { data.Set("name", "Updated test user") - response, err := editUser(testUser.Uuid, testUserCookie, app) + response, err := getRequest(testUserCookie, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - response, err = updateUser(testUser.Uuid, data, testUserCookie, app) + response, err = postRequest(data, testUserCookie, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -178,14 +178,14 @@ func TestUserManagement(t *testing.T) { t.Run("Try to update a user with an admin session", func(t *testing.T) { data.Set("name", "Updated test user by an admin") - response, err := updateUser(testUser.Uuid, data, adminCookie, app) + response, err := postRequest(data, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - response, err = updateUser(testUser.Uuid, data, adminCookie, app) + response, err = postRequest(data, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", testUser.Uuid)) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -197,7 +197,7 @@ func TestUserManagement(t *testing.T) { }) t.Run("Try to edit a non existing user with an admin session", func(t *testing.T) { - response, err := editUser("abcde", adminCookie, app) + response, err := getRequest(adminCookie, app, fmt.Sprintf("/en/users/%s/edit", "abcde")) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -211,7 +211,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to update a non existing user with an admin session", func(t *testing.T) { data.Set("name", "Updated test user by an admin") - response, err := updateUser("abcde", data, adminCookie, app) + response, err := postRequest(data, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", "abcde")) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -223,7 +223,7 @@ func TestUserManagement(t *testing.T) { } t.Run("Try to delete a user without an active session", func(t *testing.T) { - response, err := deleteUser(data, &http.Cookie{}, app) + response, err := postRequest(data, &http.Cookie{}, app, "/en/users/delete") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -234,7 +234,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete a user with a regular user's session", func(t *testing.T) { data.Set("name", "Updated test user") - response, err := deleteUser(data, testUserCookie, app) + response, err := postRequest(data, testUserCookie, app, "/en/users/delete") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -243,7 +243,7 @@ func TestUserManagement(t *testing.T) { }) t.Run("Try to delete a user with an admin session", func(t *testing.T) { - response, err := deleteUser(data, adminCookie, app) + response, err := postRequest(data, adminCookie, app, "/en/users/delete") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -259,7 +259,7 @@ func TestUserManagement(t *testing.T) { data = url.Values{ "uuid": {adminUser.Uuid}, } - response, err := deleteUser(data, adminCookie, app) + response, err := postRequest(data, adminCookie, app, "/en/users/delete") if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -281,84 +281,12 @@ func mustRedirectToUsersList(response *http.Response, t *testing.T) { } } -func mustReturnForbiddenAndShowLogin(response *http.Response, t *testing.T) { - if response.StatusCode != http.StatusForbidden { - t.Errorf("Expected status %d, received %d", http.StatusForbidden, response.StatusCode) - return - } - - doc, err := goquery.NewDocumentFromReader(response.Body) - if err != nil { - t.Fatal(err) - } - selection, err := doc.Find("head title").First().Html() - if err != nil { - t.Fatal(err) - } - if selection != "Login" { - t.Errorf("Expected login page, received %s", selection) - } -} - func mustReturnStatus(response *http.Response, expectedStatus int, t *testing.T) { if response.StatusCode != expectedStatus { t.Errorf("Expected status %d, received %d", expectedStatus, response.StatusCode) } } -func newUser(cookie *http.Cookie, app *fiber.App) (*http.Response, error) { - req, err := http.NewRequest(http.MethodGet, "/en/users/new", nil) - if err != nil { - return nil, err - } - req.AddCookie(cookie) - - return app.Test(req) -} - -func addUser(data url.Values, cookie *http.Cookie, app *fiber.App) (*http.Response, error) { - req, err := http.NewRequest(http.MethodPost, "/en/users/new", strings.NewReader(data.Encode())) - if err != nil { - return nil, err - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - - return app.Test(req) -} - -func editUser(uuid string, cookie *http.Cookie, app *fiber.App) (*http.Response, error) { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/en/users/%s/edit", uuid), nil) - if err != nil { - return nil, err - } - req.AddCookie(cookie) - - return app.Test(req) -} - -func updateUser(uuid string, data url.Values, cookie *http.Cookie, app *fiber.App) (*http.Response, error) { - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("/en/users/%s/edit", uuid), strings.NewReader(data.Encode())) - if err != nil { - return nil, err - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - - return app.Test(req) -} - -func deleteUser(data url.Values, cookie *http.Cookie, app *fiber.App) (*http.Response, error) { - req, err := http.NewRequest(http.MethodPost, "/en/users/delete", strings.NewReader(data.Encode())) - if err != nil { - return nil, err - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - - return app.Test(req) -} - func login(app *fiber.App, email, password string) (*http.Cookie, error) { data := url.Values{ "email": {email}, diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index c7d2bc71..b06572c3 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -27,17 +27,18 @@ var ( ) type Config struct { - Version string - SessionTimeout time.Duration - MinPasswordLength int - WordsPerMinute float64 - JwtSecret []byte - Hostname string - Port int - HomeDir string - LibraryPath string - CoverMaxWidth int - RequireAuth bool + Version string + SessionTimeout time.Duration + MinPasswordLength int + WordsPerMinute float64 + JwtSecret []byte + Hostname string + Port int + HomeDir string + LibraryPath string + CoverMaxWidth int + RequireAuth bool + UploadDocumentMaxSize int } type Sender interface { @@ -88,11 +89,14 @@ func New(cfg Config, controllers Controllers) *fiber.App { } app := fiber.New(fiber.Config{ - Views: engine, - DisableStartupMessage: true, - AppName: cfg.Version, - PassLocalsToViews: true, - ErrorHandler: controllers.ErrorHandler, + Views: engine, + DisableStartupMessage: true, + AppName: cfg.Version, + PassLocalsToViews: true, + ErrorHandler: controllers.ErrorHandler, + BodyLimit: cfg.UploadDocumentMaxSize * 1024 * 1024, + DisablePreParseMultipartForm: true, + StreamRequestBody: true, }) app.Use(favicon.New()) diff --git a/internal/webserver/webserver_test.go b/internal/webserver/webserver_test.go index 729a5f3a..fdd29843 100644 --- a/internal/webserver/webserver_test.go +++ b/internal/webserver/webserver_test.go @@ -1,12 +1,18 @@ package webserver_test import ( + "io/fs" "log" "net/http" + "net/url" + "os" + "path/filepath" + "strings" "sync" "testing" "time" + "github.com/PuerkitoBio/goquery" "github.com/blevesearch/bleve/v2" "github.com/gofiber/fiber/v2" "github.com/spf13/afero" @@ -58,9 +64,10 @@ func bootstrapApp(db *gorm.DB, sender webserver.Sender, appFs afero.Fs) *fiber.A } webserverConfig := webserver.Config{ - CoverMaxWidth: 600, - SessionTimeout: 24 * time.Hour, - LibraryPath: "fixtures", + CoverMaxWidth: 600, + SessionTimeout: 24 * time.Hour, + LibraryPath: "fixtures/library", + UploadDocumentMaxSize: 1, } indexFile, err := bleve.NewMemOnly(index.Mapping()) @@ -68,7 +75,7 @@ func bootstrapApp(db *gorm.DB, sender webserver.Sender, appFs afero.Fs) *fiber.A idx = index.NewBleve(indexFile, webserverConfig.LibraryPath, metadataReaders) } - err = idx.AddLibrary(afero.NewOsFs(), 100) + err = idx.AddLibrary(appFs, 100) if err != nil { log.Fatal(err) } @@ -106,3 +113,68 @@ func (s *SMTPMock) SendDocument(address string, libraryPath string, fileName str func (s *SMTPMock) From() string { return "" } + +func getRequest(cookie *http.Cookie, app *fiber.App, URL string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, URL, nil) + if err != nil { + return nil, err + } + req.AddCookie(cookie) + + return app.Test(req) +} + +func postRequest(data url.Values, cookie *http.Cookie, app *fiber.App, URL string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, URL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + + return app.Test(req) +} + +func mustReturnForbiddenAndShowLogin(response *http.Response, t *testing.T) { + if response.StatusCode != http.StatusForbidden { + t.Errorf("Expected status %d, received %d", http.StatusForbidden, response.StatusCode) + return + } + + doc, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + t.Fatal(err) + } + selection, err := doc.Find("head title").First().Html() + if err != nil { + t.Fatal(err) + } + if selection != "Login" { + t.Errorf("Expected login page, received %s", selection) + } +} + +func loadDirInMemoryFs(dir string) afero.Fs { + var ( + contents map[string][]byte + ) + + appFS := afero.NewMemMapFs() + + filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if entry.IsDir() { + return nil + } + file, err := os.Open(path) + if err != nil { + log.Fatalf("Couldn't open %s", entry.Name()) + } + _, err = file.Read(contents[path]) + if err != nil { + log.Fatalf("Couldn't read contents of %s", entry.Name()) + } + afero.WriteFile(appFS, path, contents[entry.Name()], 0644) + return nil + }) + return appFS +} diff --git a/main.go b/main.go index 3ab4bea7..125dedbc 100644 --- a/main.go +++ b/main.go @@ -77,16 +77,17 @@ func main() { } webserverConfig := webserver.Config{ - Version: version, - MinPasswordLength: cfg.MinPasswordLength, - WordsPerMinute: cfg.WordsPerMinute, - JwtSecret: cfg.JwtSecret, - Hostname: cfg.Hostname, - Port: cfg.Port, - HomeDir: homeDir, - LibraryPath: cfg.LibPath, - CoverMaxWidth: cfg.CoverMaxWidth, - RequireAuth: cfg.RequireAuth, + Version: version, + MinPasswordLength: cfg.MinPasswordLength, + WordsPerMinute: cfg.WordsPerMinute, + JwtSecret: cfg.JwtSecret, + Hostname: cfg.Hostname, + Port: cfg.Port, + HomeDir: homeDir, + LibraryPath: cfg.LibPath, + CoverMaxWidth: cfg.CoverMaxWidth, + RequireAuth: cfg.RequireAuth, + UploadDocumentMaxSize: cfg.UploadDocumentMaxSize, } webserverConfig.SessionTimeout, err = time.ParseDuration(fmt.Sprintf("%fh", cfg.SessionTimeout))