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 @@ + + +
+ + +