Monorepo for Tangled
tangled.org
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 → RepoEntry; at most one repo may have IsIndex = true.
25type DomainMapping struct {
26 Did string `json:"did"`
27 Repos map[string]RepoEntry `json:"repos"`
28}
29
30// RepoEntry is the per-repo value within a DomainMapping. Rkey is the
31// repository's atproto record key, which identifies the {did}/{rkey}/
32// prefix in R2 where the site's objects live.
33type RepoEntry struct {
34 Rkey string `json:"rkey"`
35 IsIndex bool `json:"is_index"`
36}
37
38// UnmarshalJSON makes DomainMapping tolerant of the legacy KV shape where
39// repos was map[string]bool (keyed by rkey, value = is_index). For each
40// entry it tries the new {rkey, is_index} struct first; if that fails it
41// falls back to a bare bool, using the map key itself as the rkey.
42func (m *DomainMapping) UnmarshalJSON(data []byte) error {
43 var raw struct {
44 Did string `json:"did"`
45 Repos map[string]json.RawMessage `json:"repos"`
46 }
47 if err := json.Unmarshal(data, &raw); err != nil {
48 return err
49 }
50 m.Did = raw.Did
51 m.Repos = make(map[string]RepoEntry, len(raw.Repos))
52 for name, val := range raw.Repos {
53 var entry RepoEntry
54 if err := json.Unmarshal(val, &entry); err == nil {
55 m.Repos[name] = entry
56 continue
57 }
58 // legacy shape: value is a bare bool; map key is the rkey
59 var isIndex bool
60 if err := json.Unmarshal(val, &isIndex); err != nil {
61 return fmt.Errorf("unsupported repo entry for %q: %w", name, err)
62 }
63 m.Repos[name] = RepoEntry{Rkey: name, IsIndex: isIndex}
64 }
65 return nil
66}
67
68// getOrNewMapping fetches the existing KV entry for domain, or returns a
69// fresh empty mapping for the given did if none exists yet.
70func getOrNewMapping(ctx context.Context, cf *cloudflare.Client, domain, did string) (DomainMapping, error) {
71 raw, err := cf.KVGet(ctx, domain)
72 if err != nil {
73 return DomainMapping{}, fmt.Errorf("reading domain mapping for %q: %w", domain, err)
74 }
75 if raw == nil {
76 return DomainMapping{Did: did, Repos: make(map[string]RepoEntry)}, nil
77 }
78 var m DomainMapping
79 if err := json.Unmarshal(raw, &m); err != nil {
80 return DomainMapping{}, fmt.Errorf("unmarshalling domain mapping for %q: %w", domain, err)
81 }
82 if m.Repos == nil {
83 m.Repos = make(map[string]RepoEntry)
84 }
85 return m, nil
86}
87
88// PutDomainMapping adds or updates a single repo entry within the per-domain
89// KV record. If isIndex is true, any previously indexed repo is demoted first.
90func PutDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, did, repoName, repoRkey string, isIndex bool) error {
91 m, err := getOrNewMapping(ctx, cf, domain, did)
92 if err != nil {
93 return err
94 }
95
96 m.Did = did
97
98 if isIndex {
99 for name, entry := range m.Repos {
100 if name == repoName {
101 continue
102 }
103 if entry.IsIndex {
104 entry.IsIndex = false
105 m.Repos[name] = entry
106 }
107 }
108 }
109
110 m.Repos[repoName] = RepoEntry{Rkey: repoRkey, IsIndex: isIndex}
111
112 val, err := json.Marshal(m)
113 if err != nil {
114 return fmt.Errorf("marshalling domain mapping: %w", err)
115 }
116 if err := cf.KVPut(ctx, domain, val); err != nil {
117 return fmt.Errorf("putting domain mapping for %q: %w", domain, err)
118 }
119 return nil
120}
121
122// DeleteDomainMapping removes a single repo from the per-domain KV record.
123// If it was the last repo, the key is deleted entirely.
124func DeleteDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, repoName string) error {
125 m, err := getOrNewMapping(ctx, cf, domain, "")
126 if err != nil {
127 return err
128 }
129
130 delete(m.Repos, repoName)
131
132 if len(m.Repos) == 0 {
133 if err := cf.KVDelete(ctx, domain); err != nil {
134 return fmt.Errorf("deleting domain mapping for %q: %w", domain, err)
135 }
136 return nil
137 }
138
139 val, err := json.Marshal(m)
140 if err != nil {
141 return fmt.Errorf("marshalling domain mapping: %w", err)
142 }
143 if err := cf.KVPut(ctx, domain, val); err != nil {
144 return fmt.Errorf("putting domain mapping for %q: %w", domain, err)
145 }
146 return nil
147}
148
149// DeleteAllDomainMappings removes the KV entry for a domain entirely.
150// Used when a user releases their domain claim.
151func DeleteAllDomainMappings(ctx context.Context, cf *cloudflare.Client, domain string) error {
152 if err := cf.KVDelete(ctx, domain); err != nil {
153 return fmt.Errorf("deleting all domain mappings for %q: %w", domain, err)
154 }
155 return nil
156}
157
158// prefix returns the R2 key prefix for a given repo: "{did}/{repo}/".
159// All site objects live under this prefix.
160func prefix(repoDid, repoName string) string {
161 return repoDid + "/" + repoName + "/"
162}
163
164// Deploy fetches the repo archive at the given branch from knotHost, extracts
165// deployDir from it, and syncs the resulting files to R2 via cf.SyncFiles.
166// It is the authoritative entry-point for deploying a git site.
167func Deploy(
168 ctx context.Context,
169 cf *cloudflare.Client,
170 config *config.Config,
171 f *models.Repo,
172 branch string,
173 deployDir string,
174) error {
175 tmpDir, err := os.MkdirTemp("", "tangled-sites-*")
176 if err != nil {
177 return fmt.Errorf("creating temp dir: %w", err)
178 }
179 defer os.RemoveAll(tmpDir)
180
181 if err := extractArchive(ctx, config, f, branch, tmpDir); err != nil {
182 return fmt.Errorf("extracting archive: %w", err)
183 }
184
185 // deployDir is absolute within the repo (e.g. "/" or "/docs").
186 // Map it to a path inside tmpDir.
187 deployRoot := filepath.Join(tmpDir, filepath.FromSlash(deployDir))
188
189 files := make(map[string][]byte)
190 err = filepath.WalkDir(deployRoot, func(p string, d fs.DirEntry, err error) error {
191 if err != nil {
192 return err
193 }
194 if d.IsDir() {
195 return nil
196 }
197 content, err := os.ReadFile(p)
198 if err != nil {
199 return err
200 }
201 rel, err := filepath.Rel(deployRoot, p)
202 if err != nil {
203 return err
204 }
205 files[filepath.ToSlash(rel)] = content
206 return nil
207 })
208 if err != nil {
209 return fmt.Errorf("walking deploy dir: %w", err)
210 }
211
212 if err := cf.SyncFiles(ctx, prefix(f.Did, f.Rkey), files); err != nil {
213 return fmt.Errorf("syncing files to R2: %w", err)
214 }
215
216 return nil
217}
218
219// Delete removes all R2 objects for a repo site.
220func Delete(ctx context.Context, cf *cloudflare.Client, repoDid, repoName string) error {
221 if err := cf.DeleteFiles(ctx, prefix(repoDid, repoName)); err != nil {
222 return fmt.Errorf("deleting site files from R2: %w", err)
223 }
224 return nil
225}
226
227// extractArchive fetches the tar.gz archive for the given repo+branch from
228// the knot via XRPC and extracts it into destDir.
229func extractArchive(ctx context.Context, config *config.Config, f *models.Repo, branch, destDir string) error {
230 scheme := "https"
231 if config.Core.Dev {
232 scheme = "http"
233 }
234 knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot)
235
236 xrpcc := &indigoxrpc.Client{Host: knotHost}
237 data, err := tangled.RepoArchive(ctx, xrpcc, "tar.gz", "", branch, f.RepoIdentifier())
238 if err != nil {
239 return fmt.Errorf("fetching archive: %w", err)
240 }
241
242 gz, err := gzip.NewReader(bytes.NewReader(data))
243 if err != nil {
244 return fmt.Errorf("opening gzip stream: %w", err)
245 }
246 defer gz.Close()
247
248 tr := tar.NewReader(gz)
249 for {
250 hdr, err := tr.Next()
251 if err == io.EOF {
252 break
253 }
254 if err != nil {
255 return fmt.Errorf("reading tar: %w", err)
256 }
257
258 // The knot always adds a leading prefix dir (e.g. "myrepo-main/"); strip it.
259 name := hdr.Name
260 i := strings.Index(name, "/")
261 if i < 0 {
262 continue
263 }
264 name = name[i+1:]
265 if name == "" {
266 continue
267 }
268
269 target := filepath.Join(destDir, filepath.FromSlash(name))
270
271 // Guard against zip-slip.
272 if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
273 continue
274 }
275
276 switch hdr.Typeflag {
277 case tar.TypeDir:
278 if err := os.MkdirAll(target, 0o755); err != nil {
279 return err
280 }
281 case tar.TypeReg:
282 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
283 return err
284 }
285 f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode())
286 if err != nil {
287 return err
288 }
289 if _, err := io.Copy(f, tr); err != nil {
290 f.Close()
291 return err
292 }
293 f.Close()
294 }
295 }
296
297 return nil
298}