From ca4e747bedf48d9a93df9efedaa2e285c7db26e4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:31:01 +0200 Subject: [PATCH] refactor(files): extract WriteFileDownload shared by attachment download Split the generic file-download writer (ServeContent for seekable readers, manual 304 + io.Copy otherwise) out of WriteAttachmentDownload so other blob endpoints can reuse it. The attachment writer keeps its preview branch and cache override and delegates the rest. --- pkg/web/files/file.go | 57 ++++++++++++++++++++++++++++++++ pkg/web/files/task_attachment.go | 33 +++--------------- 2 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 pkg/web/files/file.go diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go new file mode 100644 index 000000000..fa2b4a334 --- /dev/null +++ b/pkg/web/files/file.go @@ -0,0 +1,57 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package files + +import ( + "io" + "mime" + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/files" +) + +// WriteFileDownload streams a loaded file (its .File reader must be open) to the +// response as an attachment download: http.ServeContent for seekable local files +// (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) { + mimeToReturn := f.Mime + if mimeToReturn == "" { + mimeToReturn = "application/octet-stream" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": f.Name})) + w.Header().Set("Content-Type", mimeToReturn) + w.Header().Set("Content-Length", strconv.FormatUint(f.Size, 10)) + w.Header().Set("Last-Modified", f.Created.UTC().Format(http.TimeFormat)) + + // Local files are *os.File (seekable), so ServeContent gives Range + + // If-Modified-Since for free; s3 (and the in-memory test storage) return a + // non-seekable reader, so check If-Modified-Since manually and io.Copy. + if seeker, ok := f.File.(io.ReadSeeker); ok { + http.ServeContent(w, r, f.Name, f.Created, seeker) + return + } + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !f.Created.UTC().After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + _, _ = io.Copy(w, f.File) +} diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go index 3db78e62f..09306f59b 100644 --- a/pkg/web/files/task_attachment.go +++ b/pkg/web/files/task_attachment.go @@ -21,8 +21,6 @@ package files import ( - "io" - "mime" "net/http" "strconv" @@ -62,9 +60,9 @@ func toAttachmentUploadError(err error) AttachmentUploadError { return AttachmentUploadError{Message: err.Error()} } -// WriteAttachmentDownload streams the attachment (or its preview) to the response: -// http.ServeContent for seekable local files (Range + If-Modified-Since for free), -// a manual 304 + io.Copy otherwise. It closes the file reader. +// 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. func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { defer func() { _ = ta.File.File.Close() }() @@ -75,30 +73,7 @@ func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models. return } - mimeToReturn := ta.File.Mime - if mimeToReturn == "" { - mimeToReturn = "application/octet-stream" - } - w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ta.File.Name})) - w.Header().Set("Content-Type", mimeToReturn) - w.Header().Set("Content-Length", strconv.FormatUint(ta.File.Size, 10)) - w.Header().Set("Last-Modified", ta.File.Created.UTC().Format(http.TimeFormat)) // Override the global no-store directive so browsers can cache attachments. w.Header().Set("Cache-Control", "no-cache") - - // Local files are *os.File (seekable), so ServeContent gives Range + - // If-Modified-Since for free; s3 (and the in-memory test storage) return a - // non-seekable reader, so check If-Modified-Since manually and io.Copy. - if seeker, ok := ta.File.File.(io.ReadSeeker); ok { - http.ServeContent(w, r, ta.File.Name, ta.File.Created, seeker) - return - } - - if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { - if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !ta.File.Created.UTC().After(t) { - w.WriteHeader(http.StatusNotModified) - return - } - } - _, _ = io.Copy(w, ta.File.File) + WriteFileDownload(w, r, ta.File) }