This repository has no description
1package sites
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "path/filepath"
14 "strings"
15
16 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/cloudflare"
19 "tangled.org/core/appview/config"
20 "tangled.org/core/appview/models"
21)
22
23// DomainMapping is the value stored in Workers KV, keyed by the bare domain.
24// Repos maps repo name → is_index; at most one repo may have is_index = true.
25type DomainMapping struct {
26 Did string `json:"did"`
27 Repos map[string]bool `json:"repos"`
28}
29
30// getOrNewMapping fetches the existing KV entry for domain, or returns a
31// fresh empty mapping for the given did if none exists yet.
32func getOrNewMapping(ctx context.Context, cf *cloudflare.Client, domain, did string) (DomainMapping, error) {
33 raw, err := cf.KVGet(ctx, domain)
34 if err != nil {
35 return DomainMapping{}, fmt.Errorf("reading domain mapping for %q: %w", domain, err)
36 }
37 if raw == nil {
38 return DomainMapping{Did: did, Repos: make(map[string]bool)}, nil
39 }
40 var m DomainMapping
41 if err := json.Unmarshal(raw, &m); err != nil {
42 return DomainMapping{}, fmt.Errorf("unmarshalling domain mapping for %q: %w", domain, err)
43 }
44 if m.Repos == nil {
45 m.Repos = make(map[string]bool)
46 }
47 return m, nil
48}
49
50// PutDomainMapping adds or updates a single repo entry within the per-domain
51// KV record. If isIndex is true, any previously indexed repo is demoted first.
52func PutDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, did, repo string, isIndex bool) error {
53 m, err := getOrNewMapping(ctx, cf, domain, did)
54 if err != nil {
55 return err
56 }
57
58 m.Did = did
59 m.Repos[repo] = isIndex
60
61 val, err := json.Marshal(m)
62 if err != nil {
63 return fmt.Errorf("marshalling domain mapping: %w", err)
64 }
65 if err := cf.KVPut(ctx, domain, val); err != nil {
66 return fmt.Errorf("putting domain mapping for %q: %w", domain, err)
67 }
68 return nil
69}
70
71// DeleteDomainMapping removes a single repo from the per-domain KV record.
72// If it was the last repo, the key is deleted entirely.
73func DeleteDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, repo string) error {
74 m, err := getOrNewMapping(ctx, cf, domain, "")
75 if err != nil {
76 return err
77 }
78
79 delete(m.Repos, repo)
80
81 if len(m.Repos) == 0 {
82 if err := cf.KVDelete(ctx, domain); err != nil {
83 return fmt.Errorf("deleting domain mapping for %q: %w", domain, err)
84 }
85 return nil
86 }
87
88 val, err := json.Marshal(m)
89 if err != nil {
90 return fmt.Errorf("marshalling domain mapping: %w", err)
91 }
92 if err := cf.KVPut(ctx, domain, val); err != nil {
93 return fmt.Errorf("putting domain mapping for %q: %w", domain, err)
94 }
95 return nil
96}
97
98// DeleteAllDomainMappings removes the KV entry for a domain entirely.
99// Used when a user releases their domain claim.
100func DeleteAllDomainMappings(ctx context.Context, cf *cloudflare.Client, domain string) error {
101 if err := cf.KVDelete(ctx, domain); err != nil {
102 return fmt.Errorf("deleting all domain mappings for %q: %w", domain, err)
103 }
104 return nil
105}
106
107// prefix returns the R2 key prefix for a given repo: "{did}/{repo}/".
108// All site objects live under this prefix.
109func prefix(repoDid, repoName string) string {
110 return repoDid + "/" + repoName + "/"
111}
112
113// Deploy fetches the repo archive at the given branch from knotHost, extracts
114// deployDir from it, and syncs the resulting files to R2 via cf.SyncFiles.
115// It is the authoritative entry-point for deploying a git site.
116func Deploy(
117 ctx context.Context,
118 cf *cloudflare.Client,
119 config *config.Config,
120 f *models.Repo,
121 branch string,
122 deployDir string,
123) error {
124 tmpDir, err := os.MkdirTemp("", "tangled-sites-*")
125 if err != nil {
126 return fmt.Errorf("creating temp dir: %w", err)
127 }
128 defer os.RemoveAll(tmpDir)
129
130 if err := extractArchive(ctx, config, f, branch, tmpDir); err != nil {
131 return fmt.Errorf("extracting archive: %w", err)
132 }
133
134 // deployDir is absolute within the repo (e.g. "/" or "/docs").
135 // Map it to a path inside tmpDir.
136 deployRoot := filepath.Join(tmpDir, filepath.FromSlash(deployDir))
137
138 files := make(map[string][]byte)
139 err = filepath.WalkDir(deployRoot, func(p string, d fs.DirEntry, err error) error {
140 if err != nil {
141 return err
142 }
143 if d.IsDir() {
144 return nil
145 }
146 content, err := os.ReadFile(p)
147 if err != nil {
148 return err
149 }
150 rel, err := filepath.Rel(deployRoot, p)
151 if err != nil {
152 return err
153 }
154 files[filepath.ToSlash(rel)] = content
155 return nil
156 })
157 if err != nil {
158 return fmt.Errorf("walking deploy dir: %w", err)
159 }
160
161 if err := cf.SyncFiles(ctx, prefix(f.Did, f.Name), files); err != nil {
162 return fmt.Errorf("syncing files to R2: %w", err)
163 }
164
165 return nil
166}
167
168// Delete removes all R2 objects for a repo site.
169func Delete(ctx context.Context, cf *cloudflare.Client, repoDid, repoName string) error {
170 if err := cf.DeleteFiles(ctx, prefix(repoDid, repoName)); err != nil {
171 return fmt.Errorf("deleting site files from R2: %w", err)
172 }
173 return nil
174}
175
176// extractArchive fetches the tar.gz archive for the given repo+branch from
177// the knot via XRPC and extracts it into destDir.
178func extractArchive(ctx context.Context, config *config.Config, f *models.Repo, branch, destDir string) error {
179 scheme := "https"
180 if config.Core.Dev {
181 scheme = "http"
182 }
183 knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot)
184
185 xrpcc := &indigoxrpc.Client{Host: knotHost}
186 data, err := tangled.RepoArchive(ctx, xrpcc, "tar.gz", "", branch, f.RepoIdentifier())
187 if err != nil {
188 return fmt.Errorf("fetching archive: %w", err)
189 }
190
191 gz, err := gzip.NewReader(bytes.NewReader(data))
192 if err != nil {
193 return fmt.Errorf("opening gzip stream: %w", err)
194 }
195 defer gz.Close()
196
197 tr := tar.NewReader(gz)
198 for {
199 hdr, err := tr.Next()
200 if err == io.EOF {
201 break
202 }
203 if err != nil {
204 return fmt.Errorf("reading tar: %w", err)
205 }
206
207 // The knot always adds a leading prefix dir (e.g. "myrepo-main/"); strip it.
208 name := hdr.Name
209 i := strings.Index(name, "/")
210 if i < 0 {
211 continue
212 }
213 name = name[i+1:]
214 if name == "" {
215 continue
216 }
217
218 target := filepath.Join(destDir, filepath.FromSlash(name))
219
220 // Guard against zip-slip.
221 if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
222 continue
223 }
224
225 switch hdr.Typeflag {
226 case tar.TypeDir:
227 if err := os.MkdirAll(target, 0o755); err != nil {
228 return err
229 }
230 case tar.TypeReg:
231 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
232 return err
233 }
234 f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode())
235 if err != nil {
236 return err
237 }
238 if _, err := io.Copy(f, tr); err != nil {
239 f.Close()
240 return err
241 }
242 f.Close()
243 }
244 }
245
246 return nil
247}