diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 430fd51..9b30fea 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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) { diff --git a/internal/handler/download.go b/internal/handler/download.go index 1426730..4d78dca 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -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) } diff --git a/internal/r2/r2.go b/internal/r2/r2.go index 6a43d48..0437864 100644 --- a/internal/r2/r2.go +++ b/internal/r2/r2.go @@ -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 diff --git a/main.go b/main.go index d8e4122..7a5dfa7 100644 --- a/main.go +++ b/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) diff --git a/web/download.html b/web/download.html new file mode 100644 index 0000000..8d0a5c8 --- /dev/null +++ b/web/download.html @@ -0,0 +1,66 @@ + + + + + + {{.Filename}} — upload.jeffemmett.com + + + +
+

{{.Filename}}

+ +
+
+ {{if .Previewable}} + + + + + {{else}} + + + + + {{end}} +
+ +
+
+ Size + {{formatSize .Size}} +
+
+ Type + {{.ContentType}} +
+
+ Uploaded + {{.UploadedAt}} +
+ {{if .ExpiresAt}} +
+ Expires + {{.ExpiresAt}} +
+ {{end}} +
+ Downloads + {{.Downloads}} +
+
+ +
+ {{if .Previewable}} + View + {{end}} + Download +
+
+ + +
+ + diff --git a/web/static/style.css b/web/static/style.css index 7ed3f1f..41c7ff7 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; +}