diff --git a/web/fup/fuphttp/fuphttp.go b/web/fup/fuphttp/fuphttp.go index 9baff24d8a..05f2be1439 100644 --- a/web/fup/fuphttp/fuphttp.go +++ b/web/fup/fuphttp/fuphttp.go @@ -11,6 +11,7 @@ import ( "io/fs" "log" "net/http" + "strconv" "time" "github.com/google/safehtml" @@ -34,7 +35,10 @@ type Config struct { // If set, redirects to a signed URL if possible instead of serving directly. RedirectToBlobstore bool - RedirectExpiry time.Duration // Defaults to 5 minutes. + + // RedirectExpiry sets the maximum lifetime of a signed URL, if RedirectToBlobstore is in use and the backend supports signed URLs. + // Note that if a file has an expiry then the signed URL's validity will be capped at the expiry of the underlying file. + RedirectExpiry time.Duration // Defaults to 5 minutes. // Set one of these, but not both. StorageURL string @@ -99,10 +103,45 @@ func (a *Application) rawDownload(rw http.ResponseWriter, r *http.Request) { // OK } + var exp time.Time + if expStr, ok := attrs.Metadata["expires-at"]; ok { + // Check for expiry. + expSec, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + log.Printf("parsing expiration %q for %q: %v", expStr, filename, err) + a.internalError(rw, r) + return + } + + exp = time.Unix(expSec, 0) + if exp.Before(time.Now()) { + // This file has expired. Delete it from the backing storage. + err := a.storageBackend.Delete(ctx, filename) + switch gcerrors.Code(err) { + case gcerrors.NotFound: + // Something probably deleted it before we could get there. + case gcerrors.OK: + // This was fine. + default: + log.Printf("deleting expired file %q: %v", filename, err) + } + a.notFound(rw, r) + return + } + } + // Do things with attributes? if a.redirectToBlobstore { + redirectExpiry := a.redirectExpiry + if !exp.IsZero() { + // Cap the redirect lifetime to the remaining lifetime of the object. + remainingLifetime := exp.Sub(time.Now()) + if remainingLifetime < redirectExpiry { + redirectExpiry = remainingLifetime + } + } u, err := a.storageBackend.SignedURL(ctx, filename, &blob.SignedURLOptions{ - Expiry: a.redirectExpiry, + Expiry: redirectExpiry, }) switch gcerrors.Code(err) { case gcerrors.NotFound: diff --git a/web/fup/fuphttp/fuphttp_test.go b/web/fup/fuphttp/fuphttp_test.go index 6f367ca7ee..586ed7f9fb 100644 --- a/web/fup/fuphttp/fuphttp_test.go +++ b/web/fup/fuphttp/fuphttp_test.go @@ -97,6 +97,14 @@ func TestRetrieve(t *testing.T) { if err := tc.backend.WriteAll(ctx, "hello.txt", []byte("hello world\n"), nil); err != nil { t.Fatalf("WriteAll: %v", err) } + if err := tc.backend.WriteAll(ctx, "expired.txt", []byte("hello world\n"), &blob.WriterOptions{ + Metadata: map[string]string{ + // This unix timestamp is very much in the past. + "expires-at": "1000", + }, + }); err != nil { + t.Fatalf("WriteAll: %v", err) + } a, err := fuphttp.New(ctx, &ccfg) if err != nil { @@ -177,6 +185,32 @@ func TestRetrieve(t *testing.T) { t.Errorf("read data was %q; want %q", got, want) } }) + + t.Run("expired file", func(t *testing.T) { + // Verify the file is there. + if ok, err := tc.backend.Exists(ctx, "expired.txt"); err != nil { + t.Fatalf("Exists(%q) before test: %v", "expired.txt", err) + } else if !ok { + t.Fatalf("Exists(%q) before test returned false", "expired.txt") + } + + resp, err := s.Client().Get(fmt.Sprintf("%s/raw/expired.txt", s.URL)) + if err != nil { + t.Fatalf("Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("response status was %v; want %v", resp.StatusCode, http.StatusNotFound) + } + + // Verify the file is NOT there. + if ok, err := tc.backend.Exists(ctx, "expired.txt"); err != nil { + t.Fatalf("Exists(%q) after retrieving: %v", "expired.txt", err) + } else if ok { + t.Fatalf("Exists(%q) after retrieving returned true", "expired.txt") + } + }) }) } }