package veraetime import ( "bufio" "fmt" "os" "path/filepath" "regexp" "strings" "time" ) // DefaultBaseURL is the production VeraeTime API. const DefaultBaseURL = "https://api.veraetime.net" // TokenRefreshSkew is how far before expires_at a host token is refreshed. const TokenRefreshSkew = 60 * time.Second var expiryRe = regexp.MustCompile(`^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2}|Z)?$`) // DefaultAuthPath returns VERAE_AUTH_PROPERTIES or $HOME/.verae/auth.properties. func DefaultAuthPath() string { if v := strings.TrimSpace(os.Getenv("VERAE_AUTH_PROPERTIES")); v != "" { return v } home, err := os.UserHomeDir() if err != nil || home == "" { return filepath.Join(".verae", "auth.properties") } return filepath.Join(home, ".verae", "auth.properties") } // ReadProperties loads key=value lines from path. Missing file yields empty map. func ReadProperties(path string) (map[string]string, error) { f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return map[string]string{}, nil } return nil, err } defer f.Close() props := make(map[string]string) sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { continue } key, val, _ := strings.Cut(line, "=") props[strings.TrimSpace(key)] = strings.TrimSpace(val) } return props, sc.Err() } // WriteProperties merges props into path (mode 0600); parent dir 0700. func WriteProperties(path string, props map[string]string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } merged, err := ReadProperties(path) if err != nil { return err } for k, v := range props { merged[k] = v } tmp := path + ".tmp" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } for k, v := range merged { if _, err := fmt.Fprintf(f, "%s=%s\n", k, v); err != nil { f.Close() _ = os.Remove(tmp) return err } } if err := f.Close(); err != nil { _ = os.Remove(tmp) return err } _ = os.Chmod(tmp, 0o600) if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) return err } _ = os.Chmod(path, 0o600) return nil } // ParseExpiry parses VeraeTime expiresAt / expires_at strings. func ParseExpiry(value string) (time.Time, bool) { text := strings.TrimSpace(value) if text == "" { return time.Time{}, false } if strings.HasSuffix(text, "Z") { text = text[:len(text)-1] + "+00:00" } m := expiryRe.FindStringSubmatch(text) if m == nil { t, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { return time.Time{}, false } return t.UTC(), true } frac := m[2] if frac != "" { if len(frac) > 7 { frac = frac[:7] } } zone := m[3] if zone == "" || zone == "Z" { zone = "+00:00" } parsed, err := time.Parse("2006-01-02T15:04:05.999999-07:00", m[1]+frac+zone) if err != nil { parsed, err = time.Parse("2006-01-02T15:04:05-07:00", m[1]+zone) if err != nil { return time.Time{}, false } } return parsed.UTC(), true } func storedAccessToken(props map[string]string) string { for _, k := range []string{"access_token", "id_token", "auth_token"} { if v := strings.TrimSpace(props[k]); v != "" { return v } } return "" }