A vibe coded tangled fork which supports pijul.
1package cloudflare
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "mime"
8 "net/http"
9 "path/filepath"
10 "strings"
11
12 "github.com/aws/aws-sdk-go-v2/aws"
13 "github.com/aws/aws-sdk-go-v2/service/s3"
14 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15)
16
17// SyncFiles uploads the given files (keyed by relative path) to R2 under
18// prefix and deletes any objects from a previous deploy that are no longer
19// present. It is a pure R2 operation — callers are responsible for
20// constructing the prefix and fetching the source files before calling this.
21func (cl *Client) SyncFiles(ctx context.Context, prefix string, files map[string][]byte) error {
22 existingKeys, err := cl.listR2Objects(ctx, prefix)
23 if err != nil {
24 return fmt.Errorf("listing existing R2 objects: %w", err)
25 }
26
27 for relPath, content := range files {
28 key := prefix + relPath
29 _, err := cl.s3.PutObject(ctx, &s3.PutObjectInput{
30 Bucket: aws.String(cl.bucket),
31 Key: aws.String(key),
32 Body: bytes.NewReader(content),
33 ContentType: aws.String(DetectContentType(relPath, content)),
34 })
35 if err != nil {
36 return fmt.Errorf("uploading %q: %w", key, err)
37 }
38 }
39
40 for existingKey := range existingKeys {
41 relPath := strings.TrimPrefix(existingKey, prefix)
42 if _, kept := files[relPath]; !kept {
43 if err := cl.deleteR2Object(ctx, existingKey); err != nil {
44 return fmt.Errorf("deleting orphan %q: %w", existingKey, err)
45 }
46 }
47 }
48
49 return nil
50}
51
52// DeleteFiles removes all R2 objects under the given prefix.
53func (cl *Client) DeleteFiles(ctx context.Context, prefix string) error {
54 keys, err := cl.listR2Objects(ctx, prefix)
55 if err != nil {
56 return fmt.Errorf("listing R2 objects for deletion: %w", err)
57 }
58 for key := range keys {
59 if err := cl.deleteR2Object(ctx, key); err != nil {
60 return fmt.Errorf("deleting %q: %w", key, err)
61 }
62 }
63 return nil
64}
65
66// listR2Objects returns all object keys in the bucket under the given prefix,
67// handling pagination automatically.
68func (cl *Client) listR2Objects(ctx context.Context, prefix string) (map[string]struct{}, error) {
69 keys := make(map[string]struct{})
70 var continuationToken *string
71
72 for {
73 out, err := cl.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
74 Bucket: aws.String(cl.bucket),
75 Prefix: aws.String(prefix),
76 ContinuationToken: continuationToken,
77 })
78 if err != nil {
79 return nil, err
80 }
81
82 for _, obj := range out.Contents {
83 if obj.Key != nil {
84 keys[*obj.Key] = struct{}{}
85 }
86 }
87
88 if !aws.ToBool(out.IsTruncated) {
89 break
90 }
91 continuationToken = out.NextContinuationToken
92 }
93
94 return keys, nil
95}
96
97func (cl *Client) deleteR2Object(ctx context.Context, key string) error {
98 _, err := cl.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
99 Bucket: aws.String(cl.bucket),
100 Key: aws.String(key),
101 })
102 return err
103}
104
105// deleteBatch deletes up to 1000 objects in a single call.
106// Unused for now, kept for future bulk-delete optimisation.
107func (cl *Client) deleteBatch(ctx context.Context, keys []string) error {
108 if len(keys) == 0 {
109 return nil
110 }
111
112 var objects []s3types.ObjectIdentifier
113 for _, k := range keys {
114 k := k
115 objects = append(objects, s3types.ObjectIdentifier{Key: &k})
116 }
117
118 _, err := cl.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
119 Bucket: aws.String(cl.bucket),
120 Delete: &s3types.Delete{Objects: objects},
121 })
122 return err
123}
124
125// DetectContentType guesses the MIME type from the file extension, falling
126// back to sniffing the first 512 bytes of content.
127func DetectContentType(relPath string, content []byte) string {
128 if ext := filepath.Ext(relPath); ext != "" {
129 if mt := mime.TypeByExtension(ext); mt != "" {
130 return mt
131 }
132 }
133
134 sniff := content
135 if len(sniff) > 512 {
136 sniff = sniff[:512]
137 }
138 return http.DetectContentType(sniff)
139}