Monorepo for Tangled tangled.org

appview/sites: add deploy, delete, and kv domain mapping logic

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

anirudh.fi 5938fdf6 64f1f53c

verified
+236
+236
appview/sites/sites.go
··· 1 + package sites 2 + 3 + import ( 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. 23 + type 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. 30 + func 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. 50 + func 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. 71 + func 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. 98 + func 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. 107 + func 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. 114 + func 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. 164 + func 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. 173 + func 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 + }