package apps import ( "fmt" "io/fs" "net/url" "os" "path/filepath" "strings" ) // Cache stores fetched URL responses on disk under /_app/. // Files are name-keyed by upstream host + path so operators can list // and inspect them by hand. There is no metadata, no SHA-256, no // expiration — fetch-once-and-keep-forever. To force a refetch, // delete the cache file. type Cache struct { root string } // NewCache creates a Cache rooted at the given path. The directory is // created if missing. Stale *.tmp files left over from interrupted // writes are swept on construction. func NewCache(root string) (*Cache, error) { root = filepath.Clean(root) if err := os.MkdirAll(root, 0o755); err != nil { return nil, fmt.Errorf("create cache root: %w", err) } c := &Cache{root: root} if err := c.sweepTemps(); err != nil { return nil, fmt.Errorf("sweep temps: %w", err) } return c, nil } // Root returns the cache directory absolute path. func (c *Cache) Root() string { return c.root } // keyForURL converts a URL into a relative filesystem path under the // cache root, e.g. "zddc.varasys.io/releases/archive_stable.html". func keyForURL(rawURL string) (string, error) { u, err := url.Parse(rawURL) if err != nil { return "", fmt.Errorf("parse URL: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return "", fmt.Errorf("unsupported scheme %q", u.Scheme) } if u.Host == "" { return "", fmt.Errorf("URL is missing host") } if u.RawQuery != "" { return "", fmt.Errorf("URL must not contain query string: %s", rawURL) } host := strings.ToLower(u.Host) if i := strings.Index(host, ":"); i >= 0 { port := host[i+1:] hostOnly := host[:i] if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { host = hostOnly } } p := u.Path for strings.Contains(p, "//") { p = strings.ReplaceAll(p, "//", "/") } p = strings.TrimPrefix(p, "/") if p == "" { p = "index.html" } cleaned := filepath.Clean("/" + p) if strings.Contains(cleaned, "..") { return "", fmt.Errorf("URL path contains '..'") } return host + cleaned, nil } func (c *Cache) pathFor(rawURL string) (string, error) { key, err := keyForURL(rawURL) if err != nil { return "", err } return filepath.Join(c.root, filepath.FromSlash(key)), nil } // Has reports whether a cache entry exists for the URL. func (c *Cache) Has(rawURL string) bool { p, err := c.pathFor(rawURL) if err != nil { return false } _, err = os.Stat(p) return err == nil } // Read returns the cached body or os.ErrNotExist. func (c *Cache) Read(rawURL string) ([]byte, error) { p, err := c.pathFor(rawURL) if err != nil { return nil, err } return os.ReadFile(p) } // Write atomically stores body for the URL. Parent directories are // created as needed. Writes via tmp+rename so partial files are never // observable. func (c *Cache) Write(rawURL string, body []byte) error { p, err := c.pathFor(rawURL) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } return writeAtomic(p, body) } func writeAtomic(path string, data []byte) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*") if err != nil { return err } tmpName := tmp.Name() cleanup := func() { _ = os.Remove(tmpName) } if _, err := tmp.Write(data); err != nil { _ = tmp.Close() cleanup() return err } if err := tmp.Sync(); err != nil { _ = tmp.Close() cleanup() return err } if err := tmp.Close(); err != nil { cleanup() return err } if err := os.Rename(tmpName, path); err != nil { cleanup() return err } return nil } func (c *Cache) sweepTemps() error { err := filepath.WalkDir(c.root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if strings.Contains(d.Name(), ".tmp.") { _ = os.Remove(p) } return nil }) if err != nil && !os.IsNotExist(err) { return err } return nil }