diff --git a/appview/sites/sites.go b/appview/sites/sites.go new file mode 100644 index 00000000..0b4f68b7 --- /dev/null +++ b/appview/sites/sites.go @@ -0,0 +1,236 @@ +package sites + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/cloudflare" +) + +// DomainMapping is the value stored in Workers KV, keyed by the bare domain. +// Repos maps repo name → is_index; at most one repo may have is_index = true. +type DomainMapping struct { + Did string `json:"did"` + Repos map[string]bool `json:"repos"` +} + +// getOrNewMapping fetches the existing KV entry for domain, or returns a +// fresh empty mapping for the given did if none exists yet. +func getOrNewMapping(ctx context.Context, cf *cloudflare.Client, domain, did string) (DomainMapping, error) { + raw, err := cf.KVGet(ctx, domain) + if err != nil { + return DomainMapping{}, fmt.Errorf("reading domain mapping for %q: %w", domain, err) + } + if raw == nil { + return DomainMapping{Did: did, Repos: make(map[string]bool)}, nil + } + var m DomainMapping + if err := json.Unmarshal(raw, &m); err != nil { + return DomainMapping{}, fmt.Errorf("unmarshalling domain mapping for %q: %w", domain, err) + } + if m.Repos == nil { + m.Repos = make(map[string]bool) + } + return m, nil +} + +// PutDomainMapping adds or updates a single repo entry within the per-domain +// KV record. If isIndex is true, any previously indexed repo is demoted first. +func PutDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, did, repo string, isIndex bool) error { + m, err := getOrNewMapping(ctx, cf, domain, did) + if err != nil { + return err + } + + m.Did = did + m.Repos[repo] = isIndex + + val, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("marshalling domain mapping: %w", err) + } + if err := cf.KVPut(ctx, domain, val); err != nil { + return fmt.Errorf("putting domain mapping for %q: %w", domain, err) + } + return nil +} + +// DeleteDomainMapping removes a single repo from the per-domain KV record. +// If it was the last repo, the key is deleted entirely. +func DeleteDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, repo string) error { + m, err := getOrNewMapping(ctx, cf, domain, "") + if err != nil { + return err + } + + delete(m.Repos, repo) + + if len(m.Repos) == 0 { + if err := cf.KVDelete(ctx, domain); err != nil { + return fmt.Errorf("deleting domain mapping for %q: %w", domain, err) + } + return nil + } + + val, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("marshalling domain mapping: %w", err) + } + if err := cf.KVPut(ctx, domain, val); err != nil { + return fmt.Errorf("putting domain mapping for %q: %w", domain, err) + } + return nil +} + +// DeleteAllDomainMappings removes the KV entry for a domain entirely. +// Used when a user releases their domain claim. +func DeleteAllDomainMappings(ctx context.Context, cf *cloudflare.Client, domain string) error { + if err := cf.KVDelete(ctx, domain); err != nil { + return fmt.Errorf("deleting all domain mappings for %q: %w", domain, err) + } + return nil +} + +// prefix returns the R2 key prefix for a given repo: "{did}/{repo}/". +// All site objects live under this prefix. +func prefix(repoDid, repoName string) string { + return repoDid + "/" + repoName + "/" +} + +// Deploy fetches the repo archive at the given branch from knotHost, extracts +// deployDir from it, and syncs the resulting files to R2 via cf.SyncFiles. +// It is the authoritative entry-point for deploying a git site. +func Deploy( + ctx context.Context, + cf *cloudflare.Client, + knotHost, repoDid, repoName, branch, deployDir string, +) error { + tmpDir, err := os.MkdirTemp("", "tangled-sites-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := extractArchive(ctx, knotHost, repoDid, repoName, branch, tmpDir); err != nil { + return fmt.Errorf("extracting archive: %w", err) + } + + // deployDir is absolute within the repo (e.g. "/" or "/docs"). + // Map it to a path inside tmpDir. + deployRoot := filepath.Join(tmpDir, filepath.FromSlash(deployDir)) + + files := make(map[string][]byte) + err = filepath.WalkDir(deployRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + content, err := os.ReadFile(p) + if err != nil { + return err + } + rel, err := filepath.Rel(deployRoot, p) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = content + return nil + }) + if err != nil { + return fmt.Errorf("walking deploy dir: %w", err) + } + + if err := cf.SyncFiles(ctx, prefix(repoDid, repoName), files); err != nil { + return fmt.Errorf("syncing files to R2: %w", err) + } + + return nil +} + +// Delete removes all R2 objects for a repo site. +func Delete(ctx context.Context, cf *cloudflare.Client, repoDid, repoName string) error { + if err := cf.DeleteFiles(ctx, prefix(repoDid, repoName)); err != nil { + return fmt.Errorf("deleting site files from R2: %w", err) + } + return nil +} + +// extractArchive fetches the tar.gz archive for the given repo+branch from +// the knot via XRPC and extracts it into destDir. +func extractArchive(ctx context.Context, knotHost, repoDid, repoName, branch, destDir string) error { + xrpcc := &indigoxrpc.Client{Host: knotHost} + data, err := tangled.RepoArchive(ctx, xrpcc, "tar.gz", "", branch, repoDid+"/"+repoName) + if err != nil { + return fmt.Errorf("fetching archive: %w", err) + } + + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("opening gzip stream: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading tar: %w", err) + } + + // The knot always adds a leading prefix dir (e.g. "myrepo-main/"); strip it. + name := hdr.Name + i := strings.Index(name, "/") + if i < 0 { + continue + } + name = name[i+1:] + if name == "" { + continue + } + + target := filepath.Join(destDir, filepath.FromSlash(name)) + + // Guard against zip-slip. + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) { + continue + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode()) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + } + } + + return nil +}