Monorepo for Tangled tangled.org
at master 236 lines 6.5 kB view raw
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}