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