diff --git a/README.md b/README.md index 9eb9ec6..22efc17 100644 --- a/README.md +++ b/README.md @@ -107,5 +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_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. +* `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 2f98f3a..5633e58 100644 --- a/config.go +++ b/config.go @@ -32,7 +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"` - // UploadMaxSize is the maximum document size allowed to be uploaded to the library, in megabytes. + // 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. - UploadMaxSize int `env:"UPLOAD_MAX_SIZE" env-default:"20"` + UploadDocumentMaxSize int `env:"UPLOAD_DOCUMENT_MAX_SIZE" env-default:"20"` } diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go index 8c4482e..2239270 100644 --- a/internal/webserver/controller.go +++ b/internal/webserver/controller.go @@ -49,12 +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, - Hostname: cfg.Hostname, - Port: cfg.Port, + 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) @@ -126,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 7ff3529..1705a8d 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 a1ae5e2..ccbff48 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 c60a08d..b8661a1 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 0000000..8338f24 --- /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 827d41f..7b21d7e 100644 --- a/internal/webserver/controller/document/controller.go +++ b/internal/webserver/controller/document/controller.go @@ -1,9 +1,6 @@ package document import ( - "fmt" - - "github.com/gofiber/fiber/v2" "github.com/spf13/afero" "github.com/svera/coreander/v3/internal/index" "github.com/svera/coreander/v3/internal/metadata" @@ -38,12 +35,13 @@ type highlightsRepository interface { } type Config struct { - WordsPerMinute float64 - LibraryPath string - HomeDir string - CoverMaxWidth int - Hostname string - Port int + WordsPerMinute float64 + LibraryPath string + HomeDir string + CoverMaxWidth int + Hostname string + Port int + UploadDocumentMaxSize int } type Controller struct { @@ -70,12 +68,3 @@ func NewController(hlRepository highlightsRepository, sender Sender, idx IdxRead appFs: appFs, } } - -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/document/upload.go b/internal/webserver/controller/document/upload.go index 6835ce7..c6133bc 100644 --- a/internal/webserver/controller/document/upload.go +++ b/internal/webserver/controller/document/upload.go @@ -1,13 +1,18 @@ 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" ) @@ -22,22 +27,23 @@ func (d *Controller) UploadForm(c *fiber.Ctx) error { return fiber.ErrForbidden } - resetPassword := fmt.Sprintf( + upload := fmt.Sprintf( "%s://%s%s/%s/upload", c.Protocol(), d.config.Hostname, - d.urlPort(c), + controller.UrlPort(c.Protocol(), d.config.Port), c.Params("lang"), ) msg := "" - if ref := string(c.Request().Header.Referer()); strings.HasPrefix(ref, resetPassword) { + 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") } @@ -64,13 +70,29 @@ func (d *Controller) Upload(c *fiber.Ctx) error { }, "layout") } + if file.Size > int64(d.config.UploadDocumentMaxSize*1024*1024) { + errorMessage := fmt.Sprintf("Document too large, the maximum allowed size is %d megabytes", d.config.UploadDocumentMaxSize) + return c.Status(fiber.StatusRequestEntityTooLarge).Render("upload", fiber.Map{ + "Title": "Coreander", + "Error": errorMessage, + }, "layout") + } + errorMessage := "" destination := filepath.Join(d.config.LibraryPath, file.Filename) - if err := c.SaveFile(file, destination); err != nil { + + bytes, err := fileToBytes(file) + if err != nil { errorMessage = "Error uploading document" } + destFile, err := d.appFs.Create(destination) + if err != nil { + errorMessage = "Error uploading document" + } + destFile.Write(bytes) if err := d.idx.AddFile(destination); err != nil { + os.Remove(destination) errorMessage = "Error indexing document" } @@ -84,3 +106,18 @@ func (d *Controller) Upload(c *fiber.Ctx) error { 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/embedded/js/upload.js b/internal/webserver/embedded/js/upload.js new file mode 100644 index 0000000..31cc376 --- /dev/null +++ b/internal/webserver/embedded/js/upload.js @@ -0,0 +1,21 @@ + const fileSelector = document.getElementById('file-selector'); + fileSelector.addEventListener('change', (event) => { + const fileList = Array.from(event.target.files); + let fileSubmit = document.getElementById('file-submit'); + let fileSelector = document.getElementById('file-selector'); + let errorMessageContainer = document.getElementsByClassName('invalid-feedback')[0]; + + fileList.forEach(element => { + if (element.size > fileSelector.dataset.max_size) { + 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 = ''; + } + }); + }); diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 6669a88..540bfc3 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -111,4 +111,5 @@ "Upload": "Subir" "Upload document": "Subir documento" "Document uploaded successfully": "Documento subido con éxito." - \ No newline at end of file +"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" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 5936c88..1b83ade 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -111,3 +111,5 @@ "Upload": "Télécharger" "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" diff --git a/internal/webserver/embedded/views/errors/413.html b/internal/webserver/embedded/views/errors/413.html new file mode 100644 index 0000000..1dba9b0 --- /dev/null +++ b/internal/webserver/embedded/views/errors/413.html @@ -0,0 +1,3 @@ +