123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- 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(`<!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>`)
- sb.WriteString(html.EscapeString(bucketName))
- sb.WriteString(` - Public Bucket</title>
- <style>
- * { margin: 0; padding: 0; box-sizing: border-box; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- min-height: 100vh;
- padding: 20px;
- }
- .container {
- max-width: 1200px;
- margin: 0 auto;
- background: white;
- border-radius: 12px;
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
- overflow: hidden;
- }
- .header {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 30px;
- text-align: center;
- }
- .header h1 {
- font-size: 2em;
- margin-bottom: 10px;
- }
- .header p {
- opacity: 0.9;
- font-size: 0.9em;
- }
- .stats {
- display: flex;
- justify-content: space-around;
- padding: 20px;
- background: #f8f9fa;
- border-bottom: 1px solid #e9ecef;
- }
- .stat {
- text-align: center;
- }
- .stat-value {
- font-size: 2em;
- font-weight: bold;
- color: #667eea;
- }
- .stat-label {
- font-size: 0.9em;
- color: #6c757d;
- margin-top: 5px;
- }
- .file-list {
- padding: 20px;
- }
- .file-item {
- display: flex;
- align-items: center;
- padding: 15px;
- border-bottom: 1px solid #e9ecef;
- transition: background 0.2s;
- text-decoration: none;
- color: inherit;
- }
- .file-item:hover {
- background: #f8f9fa;
- }
- .file-item:last-child {
- border-bottom: none;
- }
- .file-icon {
- width: 40px;
- height: 40px;
- margin-right: 15px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: white;
- font-weight: bold;
- font-size: 0.8em;
- flex-shrink: 0;
- }
- .file-info {
- flex: 1;
- }
- .file-name {
- font-weight: 500;
- color: #212529;
- margin-bottom: 4px;
- word-break: break-all;
- }
- .file-meta {
- font-size: 0.85em;
- color: #6c757d;
- }
- .empty-state {
- text-align: center;
- padding: 60px 20px;
- color: #6c757d;
- }
- .empty-state svg {
- width: 100px;
- height: 100px;
- margin-bottom: 20px;
- opacity: 0.3;
- }
- .footer {
- text-align: center;
- padding: 20px;
- background: #f8f9fa;
- color: #6c757d;
- font-size: 0.85em;
- }
- @media (max-width: 768px) {
- .stats { flex-direction: column; gap: 15px; }
- .file-icon { width: 35px; height: 35px; }
- }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>🗂️ `)
- sb.WriteString(html.EscapeString(bucketName))
- sb.WriteString(`</h1>
- <p>Public Bucket Contents</p>
- </div>
- <div class="stats">
- <div class="stat">
- <div class="stat-value">`)
- sb.WriteString(fmt.Sprintf("%d", len(objects)))
- sb.WriteString(`</div>
- <div class="stat-label">Files</div>
- </div>
- <div class="stat">
- <div class="stat-value">`)
- totalSize := int64(0)
- for _, obj := range objects {
- totalSize += obj.Size
- }
- sb.WriteString(formatBytes(totalSize))
- sb.WriteString(`</div>
- <div class="stat-label">Total Size</div>
- </div>
- </div>
- <div class="file-list">`)
- if len(objects) == 0 {
- sb.WriteString(`
- <div class="empty-state">
- <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
- </svg>
- <p>This bucket is empty</p>
- </div>`)
- } 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(`
- <a href="%s" class="file-item">
- <div class="file-icon">%s</div>
- <div class="file-info">
- <div class="file-name">%s</div>
- <div class="file-meta">%s • %s</div>
- </div>
- </a>`,
- fileURL,
- icon,
- html.EscapeString(obj.Key),
- formatBytes(obj.Size),
- obj.LastModified.Format("Jan 02, 2006 15:04"),
- ))
- }
- }
- sb.WriteString(`
- </div>
- <div class="footer">
- <p>Powered by AWS S3 Mock Server</p>
- </div>
- </div>
- </html>`)
- 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
- }
|