| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "mime" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | tea "charm.land/bubbletea/v2" |
| 14 | |
| 15 | "github.com/stevedylandev/andromeda/apps/blobs/preview" |
| 16 | ) |
| 17 | |
| 18 | func loadBucketsCmd(s3 *S3Client) tea.Cmd { |
| 19 | return func() tea.Msg { |
| 20 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 21 | defer cancel() |
| 22 | bs, err := s3.ListBuckets(ctx) |
| 23 | return bucketsLoadedMsg{Buckets: bs, Err: err} |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | func loadListingCmd(s3 *S3Client, bucket, prefix string) tea.Cmd { |
| 28 | return func() tea.Msg { |
| 29 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 30 | defer cancel() |
| 31 | folders, files, err := s3.List(ctx, bucket, prefix) |
| 32 | return listingLoadedMsg{Bucket: bucket, Prefix: prefix, Folders: folders, Files: files, Err: err} |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | const previewMaxBytes = 5 << 20 |
| 37 | |
| 38 | func loadPreviewCmd(s3 *S3Client, proto preview.Protocol, seq int, bucket, key string, w, h int) tea.Cmd { |
| 39 | return func() tea.Msg { |
| 40 | if !isImageName(key) { |
| 41 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "")} |
| 42 | } |
| 43 | if proto == preview.ProtoNone { |
| 44 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, 0, "no preview backend (install chafa)")} |
| 45 | } |
| 46 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| 47 | defer cancel() |
| 48 | body, meta, err := s3.Get(ctx, bucket, key) |
| 49 | if err != nil { |
| 50 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err} |
| 51 | } |
| 52 | defer body.Close() |
| 53 | if meta != nil && meta.Size > previewMaxBytes { |
| 54 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, meta.Size, "too large to preview")} |
| 55 | } |
| 56 | buf, err := io.ReadAll(io.LimitReader(body, previewMaxBytes+1)) |
| 57 | if err != nil { |
| 58 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Err: err} |
| 59 | } |
| 60 | if len(buf) > previewMaxBytes { |
| 61 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "too large to preview")} |
| 62 | } |
| 63 | rendered, rerr := preview.Render(proto, buf, w, h) |
| 64 | if rerr != nil { |
| 65 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: previewMetaText(key, int64(len(buf)), "render: "+rerr.Error())} |
| 66 | } |
| 67 | return previewLoadedMsg{Seq: seq, Bucket: bucket, Key: key, Content: rendered} |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | const previewDebounce = 150 * time.Millisecond |
| 72 | |
| 73 | func debouncePreviewCmd(seq int, bucket, key string, w, h int) tea.Cmd { |
| 74 | return tea.Tick(previewDebounce, func(time.Time) tea.Msg { |
| 75 | return previewDebounceMsg{Seq: seq, Bucket: bucket, Key: key, W: w, H: h} |
| 76 | }) |
| 77 | } |
| 78 | |
| 79 | func previewMetaText(key string, size int64, note string) string { |
| 80 | ct := mime.TypeByExtension(filepath.Ext(key)) |
| 81 | if ct == "" { |
| 82 | ct = "application/octet-stream" |
| 83 | } |
| 84 | out := fmt.Sprintf("key: %s\ntype: %s", key, ct) |
| 85 | if size > 0 { |
| 86 | out += fmt.Sprintf("\nsize: %s", humanSize(size)) |
| 87 | } |
| 88 | if note != "" { |
| 89 | out += "\n\n" + note |
| 90 | } |
| 91 | return out |
| 92 | } |
| 93 | |
| 94 | func deleteCmd(s3 *S3Client, bucket, key string) tea.Cmd { |
| 95 | return func() tea.Msg { |
| 96 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 97 | defer cancel() |
| 98 | err := s3.Delete(ctx, bucket, key) |
| 99 | return deletedMsg{Key: key, Err: err} |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func uploadCmd(s3 *S3Client, cfg ClientConfig, bucket, prefix, localPath string) tea.Cmd { |
| 104 | return func() tea.Msg { |
| 105 | f, err := os.Open(localPath) |
| 106 | if err != nil { |
| 107 | return uploadedMsg{Err: err} |
| 108 | } |
| 109 | defer f.Close() |
| 110 | info, err := f.Stat() |
| 111 | if err != nil { |
| 112 | return uploadedMsg{Err: err} |
| 113 | } |
| 114 | name := filepath.Base(localPath) |
| 115 | pfx := prefix |
| 116 | if pfx != "" && !strings.HasSuffix(pfx, "/") { |
| 117 | pfx += "/" |
| 118 | } |
| 119 | key := pfx + name |
| 120 | ct := mime.TypeByExtension(filepath.Ext(name)) |
| 121 | if ct == "" { |
| 122 | ct = "application/octet-stream" |
| 123 | } |
| 124 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) |
| 125 | defer cancel() |
| 126 | if err := s3.Put(ctx, bucket, key, ct, f, info.Size()); err != nil { |
| 127 | return uploadedMsg{Key: key, Err: err} |
| 128 | } |
| 129 | url, _ := ResolveURL(ctx, s3, bucket, key) |
| 130 | return uploadedMsg{Key: key, URL: url} |
| 131 | } |
| 132 | } |