fix(files): never cache file downloads in v1 or v2

Move the Cache-Control: no-cache header into the shared WriteFileDownload
so every export and attachment download carries it, and add it to the
standalone v1 export download writer too. Downloads must never be cached.
This commit is contained in:
kolaente 2026-06-17 14:24:41 +02:00
parent b0c7bddb03
commit 91b447f020
4 changed files with 12 additions and 3 deletions

View File

@ -132,6 +132,10 @@ func DownloadUserDataExport(c *echo.Context) error {
return err return err
} }
// Downloads must never be cached; no-cache overrides the global no-store
// directive while still allowing revalidation.
c.Response().Header().Set("Cache-Control", "no-cache")
if config.FilesType.GetString() == "s3" { if config.FilesType.GetString() == "s3" {
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"") c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"")
c.Response().Header().Set("Content-Type", exportFile.Mime) c.Response().Header().Set("Content-Type", exportFile.Mime)

View File

@ -30,6 +30,10 @@ import (
// (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does // (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does
// not close the reader; the caller owns it. // not close the reader; the caller owns it.
func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) { func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) {
// Downloads must never be cached. no-cache overrides the global no-store
// directive so revalidation (If-Modified-Since) still works.
w.Header().Set("Cache-Control", "no-cache")
mimeToReturn := f.Mime mimeToReturn := f.Mime
if mimeToReturn == "" { if mimeToReturn == "" {
mimeToReturn = "application/octet-stream" mimeToReturn = "application/octet-stream"

View File

@ -62,18 +62,18 @@ func toAttachmentUploadError(err error) AttachmentUploadError {
// WriteAttachmentDownload streams the attachment (or its inline image preview) to // WriteAttachmentDownload streams the attachment (or its inline image preview) to
// the response and closes the file reader. The non-preview path delegates to // the response and closes the file reader. The non-preview path delegates to
// WriteFileDownload, adding the cache override that lets browsers cache attachments. // WriteFileDownload, which sets Cache-Control: no-cache; the preview branch returns
// early, so it sets the same header itself.
func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) {
defer func() { _ = ta.File.File.Close() }() defer func() { _ = ta.File.File.Close() }()
if preview != nil { if preview != nil {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", strconv.Itoa(len(preview))) w.Header().Set("Content-Length", strconv.Itoa(len(preview)))
_, _ = w.Write(preview) _, _ = w.Write(preview)
return return
} }
// Override the global no-store directive so browsers can cache attachments.
w.Header().Set("Cache-Control", "no-cache")
WriteFileDownload(w, r, ta.File) WriteFileDownload(w, r, ta.File)
} }

View File

@ -72,6 +72,7 @@ func TestHumaUserExport(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match") assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match")
assert.Contains(t, rec.Header().Get("Content-Disposition"), "test") assert.Contains(t, rec.Header().Get("Content-Disposition"), "test")
assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), "downloads must never be cached")
}) })
t.Run("Download with a wrong password is refused", func(t *testing.T) { t.Run("Download with a wrong password is refused", func(t *testing.T) {