diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 405f98a29..1c2fd4117 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -132,6 +132,10 @@ func DownloadUserDataExport(c *echo.Context) error { 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" { c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"") c.Response().Header().Set("Content-Type", exportFile.Mime) diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go index fa2b4a334..461fe2780 100644 --- a/pkg/web/files/file.go +++ b/pkg/web/files/file.go @@ -30,6 +30,10 @@ import ( // (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does // not close the reader; the caller owns it. 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 if mimeToReturn == "" { mimeToReturn = "application/octet-stream" diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go index 09306f59b..55945fe23 100644 --- a/pkg/web/files/task_attachment.go +++ b/pkg/web/files/task_attachment.go @@ -62,18 +62,18 @@ func toAttachmentUploadError(err error) AttachmentUploadError { // WriteAttachmentDownload streams the attachment (or its inline image preview) 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) { defer func() { _ = ta.File.File.Close() }() if preview != nil { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Length", strconv.Itoa(len(preview))) _, _ = w.Write(preview) return } - // Override the global no-store directive so browsers can cache attachments. - w.Header().Set("Cache-Control", "no-cache") WriteFileDownload(w, r, ta.File) } diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go index 4140f8aa6..ee9104d5b 100644 --- a/pkg/webtests/huma_user_export_test.go +++ b/pkg/webtests/huma_user_export_test.go @@ -72,6 +72,7 @@ func TestHumaUserExport(t *testing.T) { 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.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) {