Add download link with pretty filename from document title

- servePDF now supports ?download=1 query param
- Looks up document title and uses it as the Content-Disposition filename
- Download button on document page triggers actual download (not tab open)
- Added sanitizeFilename helper for safe Content-Disposition values
This commit is contained in:
James 2026-02-12 17:27:12 -05:00
parent 1b49dac87f
commit f59c12e25c
2 changed files with 22 additions and 1 deletions

21
main.go
View File

@ -275,6 +275,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
func servePDF(w http.ResponseWriter, r *http.Request) { func servePDF(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash") hash := chi.URLParam(r, "hash")
download := r.URL.Query().Get("download") == "1"
// Try PDF first, then TXT // Try PDF first, then TXT
for _, ext := range []string{".pdf", ".txt"} { for _, ext := range []string{".pdf", ".txt"} {
@ -285,6 +286,13 @@ func servePDF(w http.ResponseWriter, r *http.Request) {
} else { } else {
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
} }
if download {
filename := hash + ext
if doc, err := GetDocument(hash); err == nil && doc.Title != "" {
filename = sanitizeFilename(doc.Title) + ext
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
}
http.ServeFile(w, r, path) http.ServeFile(w, r, path)
return return
} }
@ -293,6 +301,13 @@ func servePDF(w http.ResponseWriter, r *http.Request) {
// Try without extension // Try without extension
path := filepath.Join(storeDir, hash) path := filepath.Join(storeDir, hash)
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
if download {
filename := hash
if doc, err := GetDocument(hash); err == nil && doc.Title != "" {
filename = sanitizeFilename(doc.Title)
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
}
http.ServeFile(w, r, path) http.ServeFile(w, r, path)
return return
} }
@ -300,6 +315,12 @@ func servePDF(w http.ResponseWriter, r *http.Request) {
http.Error(w, "File not found", http.StatusNotFound) http.Error(w, "File not found", http.StatusNotFound)
} }
// sanitizeFilename removes characters unsafe for use in Content-Disposition filenames.
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(`"`, "'", "/", "-", "\\", "-", "\n", " ", "\r", "")
return replacer.Replace(name)
}
// API handlers // API handlers
func apiSearchHandler(w http.ResponseWriter, r *http.Request) { func apiSearchHandler(w http.ResponseWriter, r *http.Request) {

View File

@ -39,7 +39,7 @@
Share Share
</button> </button>
{{if .Document.PDFPath}} {{if .Document.PDFPath}}
<a href="/pdf/{{.Document.ID}}" target="_blank" class="inline-flex items-center px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-all"> <a href="/pdf/{{.Document.ID}}?download=1" class="inline-flex items-center px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-all">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg> </svg>