Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

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}