public_view.go 10 KB

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