From 7a94ba0ccf56cd4266688d3170aa6069530cb48e Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Fri, 20 Feb 2026 10:11:18 +0000 Subject: [PATCH] appview/cloudflare: add kv, r2 and dns client wrappers Signed-off-by: Anirudh Oppiliappan --- go.mod | 18 +++++++++++++++++- go.sum | 39 +++++++++++++++++++++++++++++++++++++-- appview/cloudflare/client.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/cloudflare/dns.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/cloudflare/kv.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/cloudflare/r2.go | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/config/config.go | 31 ++++++++++++++++++++++--------- appview/dns/cloudflare.go | 53 ----------------------------------------------------- 8 file(s) changed, 421 insertion(s)(+), 65 deletion(s)(-) diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ github.com/alecthomas/assert/v2 v2.11.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/avast/retry-go/v4 v4.6.1 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/credentials v1.19.9 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/blevesearch/bleve/v2 v2.5.3 github.com/bluekeyes/go-gitdiff v0.8.1 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e @@ -15,7 +18,7 @@ github.com/carlmjohnson/versioninfo v0.22.5 github.com/casbin/casbin/v2 v2.103.0 github.com/charmbracelet/log v0.4.2 - github.com/cloudflare/cloudflare-go v0.115.0 + github.com/cloudflare/cloudflare-go/v6 v6.7.0 github.com/cyphar/filepath-securejoin v0.4.1 github.com/dgraph-io/ristretto v0.2.0 github.com/docker/docker v28.2.2+incompatible @@ -63,6 +66,15 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect github.com/alecthomas/repr v0.5.2 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -186,6 +198,10 @@ github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/vmihailenco/go-tinylfu v0.2.2 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -22,6 +22,30 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -116,8 +140,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= -github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/cloudflare/cloudflare-go/v6 v6.7.0 h1:MP6Xy5WmsyrxgTxoLeq/vraqR0nbTtXoHhW4vAYc4SY= +github.com/cloudflare/cloudflare-go/v6 v6.7.0/go.mod h1:Lj3MUqjvKctXRpdRhLQxZYRrNZHuRs0XYuH8JtQGyoI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -496,6 +520,17 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= diff --git a/appview/cloudflare/client.go b/appview/cloudflare/client.go new file mode 100644 --- /dev/null +++ b/appview/cloudflare/client.go @@ -0,0 +1,65 @@ +package cloudflare + +import ( + "fmt" + + cf "github.com/cloudflare/cloudflare-go/v6" + "github.com/cloudflare/cloudflare-go/v6/option" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "tangled.org/core/appview/config" +) + +// Client holds all cloudflare service clients used by the appview. +type Client struct { + api *cf.Client // scoped to DNS / zone operations + kvAPI *cf.Client // scoped to Workers KV (separate token) + s3 *s3.Client + zone string + kvNS string + bucket string + cfAcct string +} + +func New(c *config.Config) (*Client, error) { + api := cf.NewClient(option.WithAPIToken(c.Cloudflare.ApiToken)) + + kvAPI := cf.NewClient(option.WithAPIToken(c.Cloudflare.KV.ApiToken)) + + // R2 endpoint is S3-compatible, keyed by account id. + r2Endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", c.Cloudflare.AccountId) + + s3Client := s3.New(s3.Options{ + BaseEndpoint: aws.String(r2Endpoint), + Region: "auto", + Credentials: credentials.NewStaticCredentialsProvider( + c.Cloudflare.R2.AccessKeyID, + c.Cloudflare.R2.SecretAccessKey, + "", + ), + UsePathStyle: true, + }) + + return &Client{ + api: api, + kvAPI: kvAPI, + s3: s3Client, + zone: c.Cloudflare.ZoneId, + kvNS: c.Cloudflare.KV.NamespaceId, + bucket: c.Cloudflare.R2.Bucket, + cfAcct: c.Cloudflare.AccountId, + }, nil +} + +// Enabled returns true when the client has enough config to perform site +// operations. Callers should check this before attempting any CF operations +// so the appview degrades gracefully in dev environments without credentials. +func (cl *Client) Enabled() bool { + return cl != nil && + cl.cfAcct != "" && + cl.kvNS != "" && + cl.bucket != "" +} diff --git a/appview/cloudflare/dns.go b/appview/cloudflare/dns.go new file mode 100644 --- /dev/null +++ b/appview/cloudflare/dns.go @@ -0,0 +1,61 @@ +package cloudflare + +import ( + "context" + "fmt" + + cf "github.com/cloudflare/cloudflare-go/v6" + "github.com/cloudflare/cloudflare-go/v6/dns" +) + +type DNSRecord struct { + Type string + Name string + Content string + TTL int + Proxied bool +} + +func (cl *Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (string, error) { + var body dns.RecordNewParamsBodyUnion + + switch record.Type { + case "A": + body = dns.ARecordParam{ + Name: cf.F(record.Name), + TTL: cf.F(dns.TTL(record.TTL)), + Type: cf.F(dns.ARecordTypeA), + Content: cf.F(record.Content), + Proxied: cf.F(record.Proxied), + } + case "CNAME": + body = dns.CNAMERecordParam{ + Name: cf.F(record.Name), + TTL: cf.F(dns.TTL(record.TTL)), + Type: cf.F(dns.CNAMERecordTypeCNAME), + Content: cf.F(record.Content), + Proxied: cf.F(record.Proxied), + } + default: + return "", fmt.Errorf("unsupported DNS record type: %s", record.Type) + } + + result, err := cl.api.DNS.Records.New(ctx, dns.RecordNewParams{ + ZoneID: cf.F(cl.zone), + Body: body, + }) + if err != nil { + return "", fmt.Errorf("failed to create DNS record: %w", err) + } + return result.ID, nil +} + +func (cl *Client) DeleteDNSRecord(ctx context.Context, recordID string) error { + _, err := cl.api.DNS.Records.Delete(ctx, recordID, dns.RecordDeleteParams{ + ZoneID: cf.F(cl.zone), + }) + if err != nil { + return fmt.Errorf("failed to delete DNS record: %w", err) + } + return nil +} diff --git a/appview/cloudflare/kv.go b/appview/cloudflare/kv.go new file mode 100644 --- /dev/null +++ b/appview/cloudflare/kv.go @@ -0,0 +1,80 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "strings" + + cf "github.com/cloudflare/cloudflare-go/v6" + "github.com/cloudflare/cloudflare-go/v6/kv" + "github.com/cloudflare/cloudflare-go/v6/shared" +) + +// KVPut writes or overwrites a single Workers KV entry. +func (cl *Client) KVPut(ctx context.Context, key string, value []byte) error { + _, err := cl.kvAPI.KV.Namespaces.Values.Update(ctx, cl.kvNS, key, kv.NamespaceValueUpdateParams{ + AccountID: cf.F(cl.cfAcct), + Value: cf.F[kv.NamespaceValueUpdateParamsValueUnion](shared.UnionString(value)), + }) + if err != nil { + return fmt.Errorf("writing KV entry %q: %w", key, err) + } + return nil +} + +// KVGet reads a single Workers KV entry. Returns nil, nil if the key does not exist. +func (cl *Client) KVGet(ctx context.Context, key string) ([]byte, error) { + res, err := cl.kvAPI.KV.Namespaces.Values.Get(ctx, cl.kvNS, key, kv.NamespaceValueGetParams{ + AccountID: cf.F(cl.cfAcct), + }) + if err != nil { + // The CF SDK returns a 404 when the key doesn't exist. The error type + // lives in an internal package so we match on the error string instead. + if strings.Contains(err.Error(), "404") && strings.Contains(err.Error(), "key not found") { + return nil, nil + } + return nil, fmt.Errorf("reading KV entry %q: %w", key, err) + } + defer res.Body.Close() + val, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("reading KV entry body %q: %w", key, err) + } + return val, nil +} + +// KVDelete removes a single Workers KV entry. +// Safe to call even if the entry does not exist. +func (cl *Client) KVDelete(ctx context.Context, key string) error { + _, err := cl.kvAPI.KV.Namespaces.Values.Delete(ctx, cl.kvNS, key, kv.NamespaceValueDeleteParams{ + AccountID: cf.F(cl.cfAcct), + }) + if err != nil { + return fmt.Errorf("deleting KV entry %q: %w", key, err) + } + return nil +} + +// KVDeleteByPrefix removes every Workers KV entry whose key starts with prefix, +// handling pagination automatically. +func (cl *Client) KVDeleteByPrefix(ctx context.Context, prefix string) error { + iter := cl.kvAPI.KV.Namespaces.Keys.ListAutoPaging(ctx, cl.kvNS, kv.NamespaceKeyListParams{ + AccountID: cf.F(cl.cfAcct), + Prefix: cf.F(prefix), + }) + + for iter.Next() { + entry := iter.Current() + if _, err := cl.kvAPI.KV.Namespaces.Values.Delete(ctx, cl.kvNS, entry.Name, kv.NamespaceValueDeleteParams{ + AccountID: cf.F(cl.cfAcct), + }); err != nil { + return fmt.Errorf("deleting KV entry %q: %w", entry.Name, err) + } + } + if err := iter.Err(); err != nil { + return fmt.Errorf("listing KV entries with prefix %q: %w", prefix, err) + } + + return nil +} diff --git a/appview/cloudflare/r2.go b/appview/cloudflare/r2.go new file mode 100644 --- /dev/null +++ b/appview/cloudflare/r2.go @@ -0,0 +1,139 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// SyncFiles uploads the given files (keyed by relative path) to R2 under +// prefix and deletes any objects from a previous deploy that are no longer +// present. It is a pure R2 operation — callers are responsible for +// constructing the prefix and fetching the source files before calling this. +func (cl *Client) SyncFiles(ctx context.Context, prefix string, files map[string][]byte) error { + existingKeys, err := cl.listR2Objects(ctx, prefix) + if err != nil { + return fmt.Errorf("listing existing R2 objects: %w", err) + } + + for relPath, content := range files { + key := prefix + relPath + _, err := cl.s3.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(cl.bucket), + Key: aws.String(key), + Body: bytes.NewReader(content), + ContentType: aws.String(DetectContentType(relPath, content)), + }) + if err != nil { + return fmt.Errorf("uploading %q: %w", key, err) + } + } + + for existingKey := range existingKeys { + relPath := strings.TrimPrefix(existingKey, prefix) + if _, kept := files[relPath]; !kept { + if err := cl.deleteR2Object(ctx, existingKey); err != nil { + return fmt.Errorf("deleting orphan %q: %w", existingKey, err) + } + } + } + + return nil +} + +// DeleteFiles removes all R2 objects under the given prefix. +func (cl *Client) DeleteFiles(ctx context.Context, prefix string) error { + keys, err := cl.listR2Objects(ctx, prefix) + if err != nil { + return fmt.Errorf("listing R2 objects for deletion: %w", err) + } + for key := range keys { + if err := cl.deleteR2Object(ctx, key); err != nil { + return fmt.Errorf("deleting %q: %w", key, err) + } + } + return nil +} + +// listR2Objects returns all object keys in the bucket under the given prefix, +// handling pagination automatically. +func (cl *Client) listR2Objects(ctx context.Context, prefix string) (map[string]struct{}, error) { + keys := make(map[string]struct{}) + var continuationToken *string + + for { + out, err := cl.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(cl.bucket), + Prefix: aws.String(prefix), + ContinuationToken: continuationToken, + }) + if err != nil { + return nil, err + } + + for _, obj := range out.Contents { + if obj.Key != nil { + keys[*obj.Key] = struct{}{} + } + } + + if !aws.ToBool(out.IsTruncated) { + break + } + continuationToken = out.NextContinuationToken + } + + return keys, nil +} + +func (cl *Client) deleteR2Object(ctx context.Context, key string) error { + _, err := cl.s3.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(cl.bucket), + Key: aws.String(key), + }) + return err +} + +// deleteBatch deletes up to 1000 objects in a single call. +// Unused for now, kept for future bulk-delete optimisation. +func (cl *Client) deleteBatch(ctx context.Context, keys []string) error { + if len(keys) == 0 { + return nil + } + + var objects []s3types.ObjectIdentifier + for _, k := range keys { + k := k + objects = append(objects, s3types.ObjectIdentifier{Key: &k}) + } + + _, err := cl.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: aws.String(cl.bucket), + Delete: &s3types.Delete{Objects: objects}, + }) + return err +} + +// DetectContentType guesses the MIME type from the file extension, falling +// back to sniffing the first 512 bytes of content. +func DetectContentType(relPath string, content []byte) string { + if ext := filepath.Ext(relPath); ext != "" { + if mt := mime.TypeByExtension(ext); mt != "" { + return mt + } + } + + sniff := content + if len(sniff) > 512 { + sniff = sniff[:512] + } + return http.DetectContentType(sniff) +} diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -91,21 +91,34 @@ type R2Config struct { AccessKeyID string `env:"ACCESS_KEY_ID"` SecretAccessKey string `env:"SECRET_ACCESS_KEY"` - Bucket string `env:"BUCKET, default=tangled-pages"` + Bucket string `env:"BUCKET, default=tangled-sites"` +} + +type TurnstileConfig struct { + SiteKey string `env:"SITE_KEY"` + SecretKey string `env:"SECRET_KEY"` +} + +type KVConfig struct { + NamespaceId string `env:"NAMESPACE_ID"` + ApiToken string `env:"API_TOKEN"` } type Cloudflare struct { - ApiToken string `env:"API_TOKEN"` - ZoneId string `env:"ZONE_ID"` - AccountID string `env:"ACCOUNT_ID"` - KVNamespaceID string `env:"KV_NAMESPACE_ID"` - TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` - TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` - R2 R2Config `env:",prefix=R2_"` + // Legacy top-level API token. For services like Workers KV, we + // now use a scoped Account API token configured under the relevant + // sub-struct. + ApiToken string `env:"API_TOKEN"` + ZoneId string `env:"ZONE_ID"` + AccountId string `env:"ACCOUNT_ID"` + + KV KVConfig `env:",prefix=KV_"` + Turnstile TurnstileConfig `env:",prefix=TURNSTILE_"` + R2 R2Config `env:",prefix=R2_"` } type SitesConfig struct { - Domain string `env:"DOMAIN, default=tngl.page"` + Domain string `env:"DOMAIN, default=tngl.io"` } type LabelConfig struct { diff --git a/appview/dns/cloudflare.go b/appview/dns/cloudflare.go deleted file mode 100644 --- a/appview/dns/cloudflare.go +++ /dev/null @@ -1,53 +0,0 @@ -package dns - -import ( - "context" - "fmt" - - "github.com/cloudflare/cloudflare-go" - "tangled.org/core/appview/config" -) - -type Record struct { - Type string - Name string - Content string - TTL int - Proxied bool -} - -type Cloudflare struct { - api *cloudflare.API - zone string -} - -func NewCloudflare(c *config.Config) (*Cloudflare, error) { - apiToken := c.Cloudflare.ApiToken - api, err := cloudflare.NewWithAPIToken(apiToken) - if err != nil { - return nil, err - } - return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil -} - -func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { - result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ - Type: record.Type, - Name: record.Name, - Content: record.Content, - TTL: record.TTL, - Proxied: &record.Proxied, - }) - if err != nil { - return "", fmt.Errorf("failed to create DNS record: %w", err) - } - return result.ID, nil -} - -func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { - err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) - if err != nil { - return fmt.Errorf("failed to delete DNS record: %w", err) - } - return nil -} -- tangled.sh