feat: Add download page with view/download buttons

GET /f/{id} now shows a file info page with metadata and action buttons
instead of redirecting directly. Previewable files (images, video, audio,
text, PDF) get a "View" button (inline disposition). All files get a
"Download" button. Actual redirects moved to /f/{id}/dl and /f/{id}/view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 10:36:05 -07:00
parent 938f53b40c
commit 86edcbdc21
6 changed files with 227 additions and 5 deletions

View File

@ -3,6 +3,7 @@ package handler
import (
"database/sql"
"embed"
"fmt"
"html/template"
"log"
"net/http"
@ -15,6 +16,27 @@ var passwordTmpl *template.Template
func InitTemplates(webFS embed.FS) {
passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html"))
downloadTmpl = template.Must(template.New("download.html").Funcs(template.FuncMap{
"formatSize": formatSizeHTML,
}).ParseFS(webFS, "web/download.html"))
}
func formatSizeHTML(bytes int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func (h *Handler) AuthPage(w http.ResponseWriter, r *http.Request) {

View File

@ -4,9 +4,25 @@ import (
"database/sql"
"log"
"net/http"
"strings"
"time"
)
var downloadTmpl *template.Template
// isPreviewable returns true if the content type can be viewed inline in a browser.
func isPreviewable(contentType string) bool {
if strings.HasPrefix(contentType, "image/") ||
strings.HasPrefix(contentType, "video/") ||
strings.HasPrefix(contentType, "audio/") ||
strings.HasPrefix(contentType, "text/") ||
contentType == "application/pdf" {
return true
}
return false
}
// Download shows a file info page with view/download buttons.
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
@ -40,8 +56,67 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
}
}
// Generate presigned URL
url, err := h.r2.PresignGet(r.Context(), rec.R2Key, rec.Filename)
data := map[string]any{
"ID": rec.ID,
"Filename": rec.Filename,
"Size": rec.SizeBytes,
"ContentType": rec.ContentType,
"UploadedAt": rec.UploadedAt.Format("Jan 2, 2006 at 3:04 PM"),
"Downloads": rec.DownloadCount,
"Previewable": isPreviewable(rec.ContentType),
"ViewURL": "/f/" + id + "/view",
"DownloadURL": "/f/" + id + "/dl",
}
if rec.ExpiresAt != nil {
data["ExpiresAt"] = rec.ExpiresAt.Format("Jan 2, 2006 at 3:04 PM")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
downloadTmpl.Execute(w, data)
}
// DirectDownload generates a presigned URL and redirects (attachment disposition).
func (h *Handler) DirectDownload(w http.ResponseWriter, r *http.Request) {
h.serveFile(w, r, false)
}
// ViewFile generates a presigned URL and redirects (inline disposition).
func (h *Handler) ViewFile(w http.ResponseWriter, r *http.Request) {
h.serveFile(w, r, true)
}
func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, inline bool) {
id := r.PathValue("id")
if id == "" {
http.NotFound(w, r)
return
}
rec, err := h.store.Get(id)
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
if err != nil {
log.Printf("db get error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if rec.ExpiresAt != nil && rec.ExpiresAt.Before(time.Now().UTC()) {
http.Error(w, "this file has expired", http.StatusGone)
return
}
if rec.PasswordHash != nil {
cookie, err := r.Cookie("auth_" + id)
if err != nil || cookie.Value != "granted" {
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
return
}
}
url, err := h.r2.PresignGet(r.Context(), rec.R2Key, rec.Filename, inline)
if err != nil {
log.Printf("presign error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@ -49,6 +124,5 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
}
h.store.IncrementDownloads(id)
http.Redirect(w, r, url, http.StatusFound)
}

View File

@ -121,11 +121,16 @@ func (c *Client) uploadMultipart(ctx context.Context, key, contentType string, b
}
// PresignGet generates a presigned download URL valid for 5 minutes.
func (c *Client) PresignGet(ctx context.Context, key, filename string) (string, error) {
// If inline is true, uses inline content-disposition (view in browser).
func (c *Client) PresignGet(ctx context.Context, key, filename string, inline bool) (string, error) {
disposition := fmt.Sprintf(`attachment; filename="%s"`, filename)
if inline {
disposition = fmt.Sprintf(`inline; filename="%s"`, filename)
}
resp, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &c.bucket,
Key: &key,
ResponseContentDisposition: aws.String(fmt.Sprintf(`attachment; filename="%s"`, filename)),
ResponseContentDisposition: aws.String(disposition),
}, s3.WithPresignExpires(presignExpiry))
if err != nil {
return "", err

View File

@ -64,6 +64,8 @@ func main() {
// API
mux.HandleFunc("POST /upload", h.Upload)
mux.HandleFunc("GET /f/{id}", h.Download)
mux.HandleFunc("GET /f/{id}/dl", h.DirectDownload)
mux.HandleFunc("GET /f/{id}/view", h.ViewFile)
mux.HandleFunc("GET /f/{id}/info", h.Info)
mux.HandleFunc("GET /f/{id}/auth", h.AuthPage)
mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit)

66
web/download.html Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Filename}} — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>{{.Filename}}</h1>
<div class="file-card">
<div class="file-icon">
{{if .Previewable}}
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
{{else}}
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/>
</svg>
{{end}}
</div>
<div class="file-meta">
<div class="meta-row">
<span class="meta-label">Size</span>
<span>{{formatSize .Size}}</span>
</div>
<div class="meta-row">
<span class="meta-label">Type</span>
<span>{{.ContentType}}</span>
</div>
<div class="meta-row">
<span class="meta-label">Uploaded</span>
<span>{{.UploadedAt}}</span>
</div>
{{if .ExpiresAt}}
<div class="meta-row">
<span class="meta-label">Expires</span>
<span>{{.ExpiresAt}}</span>
</div>
{{end}}
<div class="meta-row">
<span class="meta-label">Downloads</span>
<span>{{.Downloads}}</span>
</div>
</div>
<div class="file-actions">
{{if .Previewable}}
<a href="{{.ViewURL}}" class="btn">View</a>
{{end}}
<a href="{{.DownloadURL}}" class="btn btn-secondary">Download</a>
</div>
</div>
<footer>
<a href="/">upload.jeffemmett.com</a>
</footer>
</div>
</body>
</html>

View File

@ -221,3 +221,56 @@ footer a {
footer a:hover { color: var(--accent); }
.hidden { display: none !important; }
/* Download page */
.file-card {
margin-top: 1.5rem;
padding: 1.5rem;
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--border);
}
.file-icon {
text-align: center;
color: var(--text-dim);
margin-bottom: 1.25rem;
}
.file-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.meta-row {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
}
.meta-label {
color: var(--text-dim);
}
.file-actions {
display: flex;
gap: 0.75rem;
}
.file-actions .btn {
flex: 1;
text-align: center;
text-decoration: none;
margin-top: 0;
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: #3a3a3a;
}