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:
parent
938f53b40c
commit
86edcbdc21
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue