From bd6512574465fef6b07e4091b1e7a0ba012e9dad Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Tue, 29 Nov 2022 15:52:37 +0200 Subject: [PATCH] [#1125] added support for partial/range file requests --- CHANGELOG.md | 18 ++++++++++--- apis/file.go | 4 ++- tools/filesystem/filesystem.go | 31 +++++++++++----------- tools/filesystem/filesystem_test.go | 41 ++++++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19937c8..25676179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ ## (WIP) v0.9.0 - Added new event hooks: - ``` + ```go app.OnBeforeBootstrap() app.OnAfterBootstrap() ``` - Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)). This allow writting custom migration scripts such as programmatically creating collections, - initializing default settings, running import scripts, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_). + initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_). The `migrate` command is available by default for the prebult executable, but if you use PocketBase as framework you need register it manually: @@ -20,9 +20,9 @@ Dir: migrationsDir, }) - // init the `migrate` command + // register the `migrate` command migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{ - TemplateLang: migratecmd.TemplateLangGo, // or migratecmd.TemplateLangJS + TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default) Dir: migrationsDir, Automigrate: true, }) @@ -93,6 +93,16 @@ ``` The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events. +- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)). + This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook): + ```go + // old + filesystem.Serve(res, e.ServedPath, e.ServedName) + + // new + filesystem.Serve(res, req, e.ServedPath, e.ServedName) + ``` + ## v0.8.0 diff --git a/apis/file.go b/apis/file.go index 8bcca443..dd181074 100644 --- a/apis/file.go +++ b/apis/file.go @@ -94,7 +94,9 @@ func (api *fileApi) download(c echo.Context) error { } return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error { - if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil { + res := e.HttpContext.Response() + req := e.HttpContext.Request() + if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil { return NewNotFoundError("", err) } diff --git a/tools/filesystem/filesystem.go b/tools/filesystem/filesystem.go index 3ee3c5f2..c4efd234 100644 --- a/tools/filesystem/filesystem.go +++ b/tools/filesystem/filesystem.go @@ -237,15 +237,15 @@ var manualExtensionContentTypes = map[string]string{ } // Serve serves the file at fileKey location to an HTTP response. -func (s *System) Serve(response http.ResponseWriter, fileKey string, name string) error { - r, readErr := s.bucket.NewReader(s.ctx, fileKey, nil) +func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey string, name string) error { + br, readErr := s.bucket.NewReader(s.ctx, fileKey, nil) if readErr != nil { return readErr } - defer r.Close() + defer br.Close() disposition := "attachment" - realContentType := r.ContentType() + realContentType := br.ContentType() if list.ExistInSlice(realContentType, inlineServeContentTypes) { disposition = "inline" } @@ -260,12 +260,12 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string // clickjacking shouldn't be a concern when serving uploaded files, // so it safe to unset the global X-Frame-Options to allow files embedding // (see https://github.com/pocketbase/pocketbase/issues/677) - response.Header().Del("X-Frame-Options") + res.Header().Del("X-Frame-Options") - response.Header().Set("Content-Disposition", disposition+"; filename="+name) - response.Header().Set("Content-Type", extContentType) - response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10)) - response.Header().Set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox") + res.Header().Set("Content-Disposition", disposition+"; filename="+name) + res.Header().Set("Content-Type", extContentType) + res.Header().Set("Content-Length", strconv.FormatInt(br.Size(), 10)) + res.Header().Set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox") // all HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT) // (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1) @@ -273,20 +273,19 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string // NB! time.LoadLocation may fail on non-Unix systems (see https://github.com/pocketbase/pocketbase/issues/45) location, locationErr := time.LoadLocation("GMT") if locationErr == nil { - response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST")) + res.Header().Set("Last-Modified", br.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST")) } // set a default cache-control header // (valid for 30 days but the cache is allowed to reuse the file for any requests - // that are made in the last day while revalidating the response in the background) - if response.Header().Get("Cache-Control") == "" { - response.Header().Set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400") + // that are made in the last day while revalidating the res in the background) + if res.Header().Get("Cache-Control") == "" { + res.Header().Set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400") } - // copy from the read range to response. - _, err := io.Copy(response, r) + http.ServeContent(res, req, name, br.ModTime(), br) - return err + return nil } var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`) diff --git a/tools/filesystem/filesystem_test.go b/tools/filesystem/filesystem_test.go index 3565437b..79810613 100644 --- a/tools/filesystem/filesystem_test.go +++ b/tools/filesystem/filesystem_test.go @@ -265,9 +265,10 @@ func TestFileSystemServe(t *testing.T) { } for _, scenario := range scenarios { - r := httptest.NewRecorder() + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) - err := fs.Serve(r, scenario.path, scenario.name) + err := fs.Serve(res, req, scenario.path, scenario.name) hasErr := err != nil if hasErr != scenario.expectError { @@ -279,7 +280,7 @@ func TestFileSystemServe(t *testing.T) { continue } - result := r.Result() + result := res.Result() for hName, hValue := range scenario.expectHeaders { v := result.Header.Get(hName) @@ -298,6 +299,40 @@ func TestFileSystemServe(t *testing.T) { } } +func TestFileSystemServeRange(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fs, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fs.Close() + + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Header.Add("Range", "bytes=0-20") + + if err := fs.Serve(res, req, "image.png", "image.png"); err != nil { + t.Fatal(err) + } + + result := res.Result() + + if result.StatusCode != http.StatusPartialContent { + t.Fatalf("Expected StatusCode %d, got %d", http.StatusPartialContent, result.StatusCode) + } + + expectedRange := "bytes 0-20/73" + if cr := result.Header.Get("Content-Range"); cr != expectedRange { + t.Fatalf("Expected Content-Range %q, got %q", expectedRange, cr) + } + + if l := result.Header.Get("Content-Length"); l != "21" { + t.Fatalf("Expected Content-Length %v, got %v", 21, l) + } +} + func TestFileSystemCreateThumb(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir)