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

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

+
diff --git a/internal/webserver/embedded/views/upload.html b/internal/webserver/embedded/views/upload.html index 5681bfc..ab1e530 100644 --- a/internal/webserver/embedded/views/upload.html +++ b/internal/webserver/embedded/views/upload.html @@ -23,10 +23,14 @@

{{t .Lang "Upload document" }}

- - + + +
+
- \ No newline at end of file + + + \ No newline at end of file diff --git a/internal/webserver/fixtures/file.txt b/internal/webserver/fixtures/file.txt deleted file mode 100644 index eed7e79..0000000 --- a/internal/webserver/fixtures/file.txt +++ /dev/null @@ -1 +0,0 @@ -sample \ No newline at end of file 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/haruko-html-jpeg.epub b/internal/webserver/fixtures/upload/haruko-html-jpeg.epub new file mode 100644 index 0000000..f825103 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 f4fd5d0..be594e2 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"}, diff --git a/internal/webserver/search_test.go b/internal/webserver/search_test.go index 97e31d2..c9f8599 100644 --- a/internal/webserver/search_test.go +++ b/internal/webserver/search_test.go @@ -21,7 +21,7 @@ import ( func TestSearch(t *testing.T) { db := infrastructure.Connect("file::memory:", 250) smtpMock := &SMTPMock{} - appFS := loadDirInMemoryFs("fixtures") + appFS := loadDirInMemoryFs("fixtures/library") app := bootstrapApp(db, smtpMock, appFS) @@ -117,7 +117,7 @@ func TestSendDocument(t *testing.T) { func TestRemoveDocument(t *testing.T) { db := infrastructure.Connect("file::memory:", 250) smtpMock := &SMTPMock{} - appFS := loadDirInMemoryFs("fixtures") + 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 index dba7c2d..f04c0e4 100644 --- a/internal/webserver/upload_test.go +++ b/internal/webserver/upload_test.go @@ -3,10 +3,12 @@ package webserver_test import ( "bytes" "fmt" + "log" "mime/multipart" "net/http" "net/textproto" "net/url" + "os" "testing" "github.com/gofiber/fiber/v2" @@ -83,7 +85,7 @@ func TestUpload(t *testing.T) { if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - req.Header.Add("Content-Type", multipartWriter.FormDataContentType()) + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) req.AddCookie(adminCookie) response, err := app.Test(req) @@ -96,7 +98,7 @@ func TestUpload(t *testing.T) { } }) - t.Run("Returns 200 for file content-type allowed", func(t *testing.T) { + t.Run("Returns 302 for file content-type allowed", func(t *testing.T) { var buf bytes.Buffer multipartWriter := multipart.NewWriter(&buf) @@ -111,7 +113,7 @@ func TestUpload(t *testing.T) { if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - req.Header.Add("Content-Type", multipartWriter.FormDataContentType()) + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) req.AddCookie(adminCookie) response, err := app.Test(req) @@ -133,7 +135,7 @@ func TestUpload(t *testing.T) { if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - req.Header.Add("Content-Type", multipartWriter.FormDataContentType()) + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) req.AddCookie(adminCookie) response, err := app.Test(req) @@ -145,4 +147,38 @@ func TestUpload(t *testing.T) { 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) + } + }) } diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index 2c929a9..b06572c 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -27,18 +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 - UploadMaxSize int + 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 { @@ -89,12 +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, - BodyLimit: cfg.UploadMaxSize * 1024 * 1024, + 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 89dcf87..33b5480 100644 --- a/internal/webserver/webserver_test.go +++ b/internal/webserver/webserver_test.go @@ -61,9 +61,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()) diff --git a/main.go b/main.go index 3ab4bea..125dedb 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))