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.
This commit is contained in:
kolaente 2026-06-12 10:31:01 +02:00
parent c5d615843d
commit f3ca204d67
2 changed files with 61 additions and 29 deletions

57
pkg/web/files/file.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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)
}