package handler import ( "fmt" "html" "io" "log" "net/http" "path/filepath" "strings" "aws-sts-mock/internal/kvdb" "aws-sts-mock/internal/storage" ) // PublicViewHandler handles public viewing of S3 bucket contents type PublicViewHandler struct { storage *storage.UserAwareStorage db kvdb.KVDB } // NewPublicViewHandler creates a new public view handler func NewPublicViewHandler(storage *storage.UserAwareStorage, db kvdb.KVDB) *PublicViewHandler { return &PublicViewHandler{ storage: storage, db: db, } } // Handle processes public view requests // Supports both: // - Virtual-hosted-style: bucketname.s3.domain.com/{key} // - Path-style: s3.domain.com/{bucketName}/{key} func (h *PublicViewHandler) Handle(w http.ResponseWriter, r *http.Request) { var bucketName, key string // Determine if this is virtual-hosted-style or path-style if isVirtualHostedStyle(r.Host) { // Virtual-hosted-style: bucket from Host header, key from path bucketName = extractBucketFromHost(r.Host) key = strings.TrimPrefix(r.URL.Path, "/") log.Printf("Public view (virtual-hosted): bucket=%s, key=%s", bucketName, key) } else { // Path-style: bucket and key from path path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { http.Error(w, "Invalid path format. Expected: /{bucketName}/{key}", http.StatusBadRequest) return } parts := strings.SplitN(path, "/", 2) bucketName = parts[0] if len(parts) > 1 { key = parts[1] } log.Printf("Public view (path-style): bucket=%s, key=%s", bucketName, key) } // Resolve bucket name to accountID and bucketID accountID, bucketID, err := h.db.ResolveBucketName(bucketName) if err != nil { log.Printf("Failed to resolve bucket name %s: %v", bucketName, err) http.Error(w, "Bucket not found", http.StatusNotFound) return } // Check if bucket has public viewing enabled config, err := h.db.GetBucketConfig(accountID, bucketID) if err != nil { log.Printf("Bucket config not found for %s:%s", accountID, bucketID) http.Error(w, "Bucket not found or not configured for public access", http.StatusNotFound) return } if !config.PublicViewing { http.Error(w, "Public viewing not enabled for this bucket", http.StatusForbidden) return } // If no key specified, show bucket listing if key == "" { h.handleBucketListing(w, r, accountID, bucketID, config) return } // Serve the file h.handleFileDownload(w, r, accountID, bucketID, bucketName, key) } func (h *PublicViewHandler) handleBucketListing(w http.ResponseWriter, r *http.Request, accountID, bucketID string, config *kvdb.BucketConfig) { // List all objects in the bucket objects, err := h.storage.ListObjectsByBucketIDForUser(accountID, bucketID) if err != nil { log.Printf("Error listing objects: %v", err) http.Error(w, "Error listing bucket contents", http.StatusInternalServerError) return } // Generate HTML page htmlContent := h.generateBucketListingHTML(config.BucketName, objects, r) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(htmlContent)) } func (h *PublicViewHandler) handleFileDownload(w http.ResponseWriter, r *http.Request, accountID, bucketID, bucketName, key string) { // Get the file file, info, err := h.storage.GetObjectByBucketIDForUser(accountID, bucketID, key) if err != nil { if err == storage.ErrObjectNotFound { http.Error(w, "File not found", http.StatusNotFound) return } log.Printf("Error getting object: %v", err) http.Error(w, "Error retrieving file", http.StatusInternalServerError) return } defer file.Close() // Set headers w.Header().Set("Content-Type", getContentType(key)) w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size)) w.Header().Set("Last-Modified", info.LastModified.UTC().Format(http.TimeFormat)) w.Header().Set("ETag", fmt.Sprintf("\"%s\"", info.ETag)) // If it's an image, set inline disposition, otherwise attachment ext := strings.ToLower(filepath.Ext(key)) if isImage(ext) || isText(ext) || ext == ".pdf" { w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(key))) } else { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(key))) } w.WriteHeader(http.StatusOK) // Stream the file http.ServeContent(w, r, filepath.Base(key), info.LastModified, file.(io.ReadSeeker)) } func (h *PublicViewHandler) generateBucketListingHTML(bucketName string, objects []storage.ObjectInfo, r *http.Request) string { var sb strings.Builder // Determine URL prefix based on request style var urlPrefix string if isVirtualHostedStyle(r.Host) { // Virtual-hosted-style: just use the path urlPrefix = "" } else { // Path-style: include bucket name in URL urlPrefix = "/" + bucketName } sb.WriteString(` `) sb.WriteString(html.EscapeString(bucketName)) sb.WriteString(` - Public Bucket

🗂️ `) sb.WriteString(html.EscapeString(bucketName)) sb.WriteString(`

Public Bucket Contents

`) sb.WriteString(fmt.Sprintf("%d", len(objects))) sb.WriteString(`
Files
`) totalSize := int64(0) for _, obj := range objects { totalSize += obj.Size } sb.WriteString(formatBytes(totalSize)) sb.WriteString(`
Total Size
`) if len(objects) == 0 { sb.WriteString(`

This bucket is empty

`) } else { for _, obj := range objects { ext := strings.ToLower(filepath.Ext(obj.Key)) icon := getFileIcon(ext) // Build the correct URL based on style fileURL := urlPrefix + "/" + obj.Key sb.WriteString(fmt.Sprintf(`
%s
%s
%s • %s
`, fileURL, icon, html.EscapeString(obj.Key), formatBytes(obj.Size), obj.LastModified.Format("Jan 02, 2006 15:04"), )) } } sb.WriteString(`
`) return sb.String() } func formatBytes(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } func getFileIcon(ext string) string { switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg": return "🖼️" case ".pdf": return "📄" case ".doc", ".docx": return "📝" case ".xls", ".xlsx": return "📊" case ".zip", ".tar", ".gz", ".rar": return "📦" case ".mp4", ".avi", ".mov", ".mkv": return "🎬" case ".mp3", ".wav", ".flac": return "🎵" case ".txt", ".md": return "📃" case ".js", ".ts", ".go", ".py", ".java": return "💻" default: return "📁" } } func getContentType(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".svg": return "image/svg+xml" case ".pdf": return "application/pdf" case ".json": return "application/json" case ".xml": return "application/xml" case ".txt": return "text/plain" case ".html": return "text/html" case ".css": return "text/css" case ".js": return "application/javascript" case ".mp4": return "video/mp4" case ".mp3": return "audio/mpeg" default: return "application/octet-stream" } } func isImage(ext string) bool { switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp": return true } return false } func isText(ext string) bool { switch ext { case ".txt", ".md", ".html", ".css", ".js", ".json", ".xml", ".csv": return true } return false }