public_view.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. package handler
  2. import (
  3. "fmt"
  4. "html"
  5. "io"
  6. "log"
  7. "net/http"
  8. "path/filepath"
  9. "strings"
  10. "aws-sts-mock/internal/kvdb"
  11. "aws-sts-mock/internal/storage"
  12. )
  13. // PublicViewHandler handles public viewing of S3 bucket contents
  14. type PublicViewHandler struct {
  15. storage *storage.UserAwareStorage
  16. db kvdb.KVDB
  17. }
  18. // NewPublicViewHandler creates a new public view handler
  19. func NewPublicViewHandler(storage *storage.UserAwareStorage, db kvdb.KVDB) *PublicViewHandler {
  20. return &PublicViewHandler{
  21. storage: storage,
  22. db: db,
  23. }
  24. }
  25. // Handle processes public view requests
  26. // Supports both:
  27. // - Virtual-hosted-style: bucketname.s3.domain.com/{key}
  28. // - Path-style: s3.domain.com/{bucketName}/{key}
  29. func (h *PublicViewHandler) Handle(w http.ResponseWriter, r *http.Request) {
  30. var bucketName, key string
  31. // Determine if this is virtual-hosted-style or path-style
  32. if isVirtualHostedStyle(r.Host) {
  33. // Virtual-hosted-style: bucket from Host header, key from path
  34. bucketName = extractBucketFromHost(r.Host)
  35. key = strings.TrimPrefix(r.URL.Path, "/")
  36. log.Printf("Public view (virtual-hosted): bucket=%s, key=%s", bucketName, key)
  37. } else {
  38. // Path-style: bucket and key from path
  39. path := strings.TrimPrefix(r.URL.Path, "/")
  40. if path == "" {
  41. http.Error(w, "Invalid path format. Expected: /{bucketName}/{key}", http.StatusBadRequest)
  42. return
  43. }
  44. parts := strings.SplitN(path, "/", 2)
  45. bucketName = parts[0]
  46. if len(parts) > 1 {
  47. key = parts[1]
  48. }
  49. log.Printf("Public view (path-style): bucket=%s, key=%s", bucketName, key)
  50. }
  51. // Resolve bucket name to accountID and bucketID
  52. accountID, bucketID, err := h.db.ResolveBucketName(bucketName)
  53. if err != nil {
  54. log.Printf("Failed to resolve bucket name %s: %v", bucketName, err)
  55. http.Error(w, "Bucket not found", http.StatusNotFound)
  56. return
  57. }
  58. // Check if bucket has public viewing enabled
  59. config, err := h.db.GetBucketConfig(accountID, bucketID)
  60. if err != nil {
  61. log.Printf("Bucket config not found for %s:%s", accountID, bucketID)
  62. http.Error(w, "Bucket not found or not configured for public access", http.StatusNotFound)
  63. return
  64. }
  65. if !config.PublicViewing {
  66. http.Error(w, "Public viewing not enabled for this bucket", http.StatusForbidden)
  67. return
  68. }
  69. // If no key specified, show bucket listing
  70. if key == "" {
  71. h.handleBucketListing(w, r, accountID, bucketID, config)
  72. return
  73. }
  74. // Serve the file
  75. h.handleFileDownload(w, r, accountID, bucketID, bucketName, key)
  76. }
  77. func (h *PublicViewHandler) handleBucketListing(w http.ResponseWriter, r *http.Request, accountID, bucketID string, config *kvdb.BucketConfig) {
  78. // List all objects in the bucket
  79. objects, err := h.storage.ListObjectsByBucketIDForUser(accountID, bucketID)
  80. if err != nil {
  81. log.Printf("Error listing objects: %v", err)
  82. http.Error(w, "Error listing bucket contents", http.StatusInternalServerError)
  83. return
  84. }
  85. // Generate HTML page
  86. htmlContent := h.generateBucketListingHTML(config.BucketName, objects, r)
  87. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  88. w.WriteHeader(http.StatusOK)
  89. w.Write([]byte(htmlContent))
  90. }
  91. func (h *PublicViewHandler) handleFileDownload(w http.ResponseWriter, r *http.Request, accountID, bucketID, bucketName, key string) {
  92. // Get the file
  93. file, info, err := h.storage.GetObjectByBucketIDForUser(accountID, bucketID, key)
  94. if err != nil {
  95. if err == storage.ErrObjectNotFound {
  96. http.Error(w, "File not found", http.StatusNotFound)
  97. return
  98. }
  99. log.Printf("Error getting object: %v", err)
  100. http.Error(w, "Error retrieving file", http.StatusInternalServerError)
  101. return
  102. }
  103. defer file.Close()
  104. // Set headers
  105. w.Header().Set("Content-Type", getContentType(key))
  106. w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
  107. w.Header().Set("Last-Modified", info.LastModified.UTC().Format(http.TimeFormat))
  108. w.Header().Set("ETag", fmt.Sprintf("\"%s\"", info.ETag))
  109. // If it's an image, set inline disposition, otherwise attachment
  110. ext := strings.ToLower(filepath.Ext(key))
  111. if isImage(ext) || isText(ext) || ext == ".pdf" {
  112. w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(key)))
  113. } else {
  114. w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(key)))
  115. }
  116. w.WriteHeader(http.StatusOK)
  117. // Stream the file
  118. http.ServeContent(w, r, filepath.Base(key), info.LastModified, file.(io.ReadSeeker))
  119. }
  120. func (h *PublicViewHandler) generateBucketListingHTML(bucketName string, objects []storage.ObjectInfo, r *http.Request) string {
  121. var sb strings.Builder
  122. // Determine URL prefix based on request style
  123. var urlPrefix string
  124. if isVirtualHostedStyle(r.Host) {
  125. // Virtual-hosted-style: just use the path
  126. urlPrefix = ""
  127. } else {
  128. // Path-style: include bucket name in URL
  129. urlPrefix = "/" + bucketName
  130. }
  131. sb.WriteString(`<!DOCTYPE html>
  132. <html lang="en">
  133. <head>
  134. <meta charset="UTF-8">
  135. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  136. <title>`)
  137. sb.WriteString(html.EscapeString(bucketName))
  138. sb.WriteString(` - Public Bucket</title>
  139. <style>
  140. * { margin: 0; padding: 0; box-sizing: border-box; }
  141. body {
  142. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  143. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  144. min-height: 100vh;
  145. padding: 20px;
  146. }
  147. .container {
  148. max-width: 1200px;
  149. margin: 0 auto;
  150. background: white;
  151. border-radius: 12px;
  152. box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  153. overflow: hidden;
  154. }
  155. .header {
  156. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  157. color: white;
  158. padding: 30px;
  159. text-align: center;
  160. }
  161. .header h1 {
  162. font-size: 2em;
  163. margin-bottom: 10px;
  164. }
  165. .header p {
  166. opacity: 0.9;
  167. font-size: 0.9em;
  168. }
  169. .stats {
  170. display: flex;
  171. justify-content: space-around;
  172. padding: 20px;
  173. background: #f8f9fa;
  174. border-bottom: 1px solid #e9ecef;
  175. }
  176. .stat {
  177. text-align: center;
  178. }
  179. .stat-value {
  180. font-size: 2em;
  181. font-weight: bold;
  182. color: #667eea;
  183. }
  184. .stat-label {
  185. font-size: 0.9em;
  186. color: #6c757d;
  187. margin-top: 5px;
  188. }
  189. .file-list {
  190. padding: 20px;
  191. }
  192. .file-item {
  193. display: flex;
  194. align-items: center;
  195. padding: 15px;
  196. border-bottom: 1px solid #e9ecef;
  197. transition: background 0.2s;
  198. text-decoration: none;
  199. color: inherit;
  200. }
  201. .file-item:hover {
  202. background: #f8f9fa;
  203. }
  204. .file-item:last-child {
  205. border-bottom: none;
  206. }
  207. .file-icon {
  208. width: 40px;
  209. height: 40px;
  210. margin-right: 15px;
  211. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  212. border-radius: 8px;
  213. display: flex;
  214. align-items: center;
  215. justify-content: center;
  216. color: white;
  217. font-weight: bold;
  218. font-size: 0.8em;
  219. flex-shrink: 0;
  220. }
  221. .file-info {
  222. flex: 1;
  223. }
  224. .file-name {
  225. font-weight: 500;
  226. color: #212529;
  227. margin-bottom: 4px;
  228. word-break: break-all;
  229. }
  230. .file-meta {
  231. font-size: 0.85em;
  232. color: #6c757d;
  233. }
  234. .empty-state {
  235. text-align: center;
  236. padding: 60px 20px;
  237. color: #6c757d;
  238. }
  239. .empty-state svg {
  240. width: 100px;
  241. height: 100px;
  242. margin-bottom: 20px;
  243. opacity: 0.3;
  244. }
  245. .footer {
  246. text-align: center;
  247. padding: 20px;
  248. background: #f8f9fa;
  249. color: #6c757d;
  250. font-size: 0.85em;
  251. }
  252. @media (max-width: 768px) {
  253. .stats { flex-direction: column; gap: 15px; }
  254. .file-icon { width: 35px; height: 35px; }
  255. }
  256. </style>
  257. </head>
  258. <body>
  259. <div class="container">
  260. <div class="header">
  261. <h1>🗂️ `)
  262. sb.WriteString(html.EscapeString(bucketName))
  263. sb.WriteString(`</h1>
  264. <p>Public Bucket Contents</p>
  265. </div>
  266. <div class="stats">
  267. <div class="stat">
  268. <div class="stat-value">`)
  269. sb.WriteString(fmt.Sprintf("%d", len(objects)))
  270. sb.WriteString(`</div>
  271. <div class="stat-label">Files</div>
  272. </div>
  273. <div class="stat">
  274. <div class="stat-value">`)
  275. totalSize := int64(0)
  276. for _, obj := range objects {
  277. totalSize += obj.Size
  278. }
  279. sb.WriteString(formatBytes(totalSize))
  280. sb.WriteString(`</div>
  281. <div class="stat-label">Total Size</div>
  282. </div>
  283. </div>
  284. <div class="file-list">`)
  285. if len(objects) == 0 {
  286. sb.WriteString(`
  287. <div class="empty-state">
  288. <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
  289. <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"/>
  290. </svg>
  291. <p>This bucket is empty</p>
  292. </div>`)
  293. } else {
  294. for _, obj := range objects {
  295. ext := strings.ToLower(filepath.Ext(obj.Key))
  296. icon := getFileIcon(ext)
  297. // Build the correct URL based on style
  298. fileURL := urlPrefix + "/" + obj.Key
  299. sb.WriteString(fmt.Sprintf(`
  300. <a href="%s" class="file-item">
  301. <div class="file-icon">%s</div>
  302. <div class="file-info">
  303. <div class="file-name">%s</div>
  304. <div class="file-meta">%s • %s</div>
  305. </div>
  306. </a>`,
  307. fileURL,
  308. icon,
  309. html.EscapeString(obj.Key),
  310. formatBytes(obj.Size),
  311. obj.LastModified.Format("Jan 02, 2006 15:04"),
  312. ))
  313. }
  314. }
  315. sb.WriteString(`
  316. </div>
  317. <div class="footer">
  318. <p>Powered by AWS S3 Mock Server</p>
  319. </div>
  320. </div>
  321. </html>`)
  322. return sb.String()
  323. }
  324. func formatBytes(bytes int64) string {
  325. const unit = 1024
  326. if bytes < unit {
  327. return fmt.Sprintf("%d B", bytes)
  328. }
  329. div, exp := int64(unit), 0
  330. for n := bytes / unit; n >= unit; n /= unit {
  331. div *= unit
  332. exp++
  333. }
  334. return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
  335. }
  336. func getFileIcon(ext string) string {
  337. switch ext {
  338. case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg":
  339. return "🖼️"
  340. case ".pdf":
  341. return "📄"
  342. case ".doc", ".docx":
  343. return "📝"
  344. case ".xls", ".xlsx":
  345. return "📊"
  346. case ".zip", ".tar", ".gz", ".rar":
  347. return "📦"
  348. case ".mp4", ".avi", ".mov", ".mkv":
  349. return "🎬"
  350. case ".mp3", ".wav", ".flac":
  351. return "🎵"
  352. case ".txt", ".md":
  353. return "📃"
  354. case ".js", ".ts", ".go", ".py", ".java":
  355. return "💻"
  356. default:
  357. return "📁"
  358. }
  359. }
  360. func getContentType(filename string) string {
  361. ext := strings.ToLower(filepath.Ext(filename))
  362. switch ext {
  363. case ".jpg", ".jpeg":
  364. return "image/jpeg"
  365. case ".png":
  366. return "image/png"
  367. case ".gif":
  368. return "image/gif"
  369. case ".webp":
  370. return "image/webp"
  371. case ".svg":
  372. return "image/svg+xml"
  373. case ".pdf":
  374. return "application/pdf"
  375. case ".json":
  376. return "application/json"
  377. case ".xml":
  378. return "application/xml"
  379. case ".txt":
  380. return "text/plain"
  381. case ".html":
  382. return "text/html"
  383. case ".css":
  384. return "text/css"
  385. case ".js":
  386. return "application/javascript"
  387. case ".mp4":
  388. return "video/mp4"
  389. case ".mp3":
  390. return "audio/mpeg"
  391. default:
  392. return "application/octet-stream"
  393. }
  394. }
  395. func isImage(ext string) bool {
  396. switch ext {
  397. case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp":
  398. return true
  399. }
  400. return false
  401. }
  402. func isText(ext string) bool {
  403. switch ext {
  404. case ".txt", ".md", ".html", ".css", ".js", ".json", ".xml", ".csv":
  405. return true
  406. }
  407. return false
  408. }