//go:build linux || freebsd // +build linux freebsd package cookie import ( "bufio" "fmt" "net/http" "net/http/cookiejar" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "codeberg.org/petrbalvin/goget/internal/core" "golang.org/x/net/publicsuffix" ) // Jar wraps http.CookieJar with Netscape format import/export. type Jar struct { *cookiejar.Jar mu sync.Mutex cookies map[string][]*http.Cookie // domain -> cookies (for export) } // NewJar creates a new cookie jar with public suffix support. func NewJar() (*Jar, error) { jar, err := cookiejar.New(&cookiejar.Options{ PublicSuffixList: publicsuffix.List, }) if err != nil { return nil, err } return &Jar{ Jar: jar, cookies: make(map[string][]*http.Cookie), }, nil } // SetCookies stores cookies in the jar and tracks them for export. func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) { j.Jar.SetCookies(u, cookies) j.mu.Lock() defer j.mu.Unlock() for _, c := range cookies { // Key the export map by the cookie's effective domain, not the // URL hostname. A cookie set for "example.com" via a request to // "www.example.com" must round-trip through SaveToFile/LoadFromFile // keyed by "example.com", otherwise the second SetCookies call // (from LoadFromFile) lands under a different map key and the // cookies appear to be silently lost between sessions. key := cookieStorageKey(c, u) j.cookies[key] = append(j.cookies[key], c) } } // cookieStorageKey returns the canonical (no leading dot) domain used as // the storage key for the export map. It prefers the cookie's own Domain // attribute (which reflects the cookie's actual scope) and falls back to // the URL hostname for host-only cookies (those without a Domain attr). func cookieStorageKey(c *http.Cookie, u *url.URL) string { if c.Domain != "" { return strings.TrimPrefix(c.Domain, ".") } return u.Hostname() } // LoadFromFile loads cookies from a file in Netscape format. func (j *Jar) LoadFromFile(path string) error { if path == "" { return nil } file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("failed to open cookie jar: %w", err) } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Split(line, "\t") if len(fields) < 7 { continue } domain := fields[0] path := fields[2] expiresStr := fields[4] name := fields[5] value := fields[6] expires, err := strconv.ParseInt(expiresStr, 10, 64) if err != nil { continue } if expires > 0 && time.Now().Unix() > expires { continue } // Strip the leading dot from the Netscape-format domain so the // round-trip back through SetCookies uses a stable key. Go's // net/url strips the leading dot from Hostname() since Go 1.20, // but matching that behaviour here makes the export map key // independent of Go stdlib version. domainBare := strings.TrimPrefix(domain, ".") scheme := "https" if fields[3] != "TRUE" { scheme = "http" } u := &url.URL{ Scheme: scheme, Host: domainBare, Path: path, } cookie := &http.Cookie{ Name: name, Value: value, Path: path, Domain: domainBare, Expires: time.Unix(expires, 0), Secure: fields[3] == "TRUE", } j.SetCookies(u, []*http.Cookie{cookie}) } return scanner.Err() } // SaveToFile saves cookies to a file in Netscape format. func (j *Jar) SaveToFile(path string) error { if path == "" { return nil } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return fmt.Errorf("failed to create cookie jar: %w", err) } defer file.Close() fmt.Fprintln(file, "# Netscape HTTP Cookie File") fmt.Fprintf(file, "# Generated by goget %s at %s\n", core.Version, time.Now().Format(time.RFC3339)) fmt.Fprintln(file, "# Format: domain flag path secure expiration name value") fmt.Fprintln(file) j.mu.Lock() defer j.mu.Unlock() for domain, cookies := range j.cookies { for _, c := range cookies { secure := "FALSE" if c.Secure { secure = "TRUE" } // The map key is always stored without a leading dot, but // the Netscape format expects one — always emit it. expires := c.Expires.Unix() if expires <= 0 { expires = time.Now().Add(365 * 24 * time.Hour).Unix() } fmt.Fprintf(file, ".%s\t%s\t%s\t%s\t%d\t%s\t%s\n", domain, "TRUE", c.Path, secure, expires, c.Name, c.Value, ) } } return nil } // GetCookieCount returns the number of cookies for a given URL. func (j *Jar) GetCookieCount(u *url.URL) int { return len(j.Cookies(u)) }