This repository has no description
0

Configure Feed

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

core / appview / sites / sites.go
6.7 kB 247 lines
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}