package profiles import ( "fmt" "io/fs" "log/slog" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/pinchtab/pinchtab/internal/bridge" "github.com/pinchtab/pinchtab/internal/idutil" ) var idMgr = idutil.NewManager() func profileID(name string) string { return idMgr.ProfileID(name) } // ValidateProfileName checks that a profile name is safe and doesn't contain // path traversal characters like "..", "/", or "\". func ValidateProfileName(name string) error { if name == "" { return fmt.Errorf("profile name cannot be empty") } if strings.Contains(name, "..") { return fmt.Errorf("profile name cannot contain '..'") } if strings.ContainsAny(name, "/\\") { return fmt.Errorf("profile name cannot contain '/' or '\\'") } return nil } type ProfileManager struct { baseDir string tracker *ActionTracker mu sync.RWMutex } type ProfileMeta struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` UseWhen string `json:"useWhen,omitempty"` Description string `json:"description,omitempty"` } type ProfileDetailedInfo struct { ID string `json:"id,omitempty"` Name string `json:"name"` Path string `json:"path"` CreatedAt time.Time `json:"createdAt"` SizeMB float64 `json:"sizeMB"` Source string `json:"source,omitempty"` ChromeProfileName string `json:"chromeProfileName,omitempty"` AccountEmail string `json:"accountEmail,omitempty"` AccountName string `json:"accountName,omitempty"` HasAccount bool `json:"hasAccount,omitempty"` UseWhen string `json:"useWhen,omitempty"` Description string `json:"description,omitempty"` } func NewProfileManager(baseDir string) *ProfileManager { _ = os.MkdirAll(baseDir, 0755) return &ProfileManager{ baseDir: baseDir, tracker: NewActionTracker(), } } func (pm *ProfileManager) findProfileDirByName(name string) (string, error) { direct := filepath.Join(pm.baseDir, name) if info, err := os.Stat(direct); err == nil && info.IsDir() { return direct, nil } entries, err := os.ReadDir(pm.baseDir) if err != nil { return "", err } for _, entry := range entries { if !entry.IsDir() { continue } dir := filepath.Join(pm.baseDir, entry.Name()) if entry.Name() == profileID(name) { return dir, nil } meta := readProfileMeta(dir) if meta.Name == name { return dir, nil } } return "", fmt.Errorf("profile %q not found", name) } func (pm *ProfileManager) profileDir(name string) (string, error) { if err := ValidateProfileName(name); err != nil { return "", err } pm.mu.RLock() defer pm.mu.RUnlock() return pm.findProfileDirByName(name) } func (pm *ProfileManager) Exists(name string) bool { _, err := pm.profileDir(name) return err == nil } func (pm *ProfileManager) ProfilePath(name string) (string, error) { return pm.profileDir(name) } func (pm *ProfileManager) List() ([]bridge.ProfileInfo, error) { pm.mu.RLock() defer pm.mu.RUnlock() entries, err := os.ReadDir(pm.baseDir) if err != nil { return nil, err } profiles := []bridge.ProfileInfo{} skip := map[string]bool{"bin": true, "profiles": true} for _, entry := range entries { if !entry.IsDir() || skip[entry.Name()] { continue } info, err := pm.profileInfo(entry.Name()) if err != nil { continue } if _, err := os.Stat(filepath.Join(pm.baseDir, entry.Name(), "Default")); err != nil { continue } isTemporary := strings.HasPrefix(info.Name, "instance-") pathExists := true if _, err := os.Stat(info.Path); err != nil { pathExists = false } profiles = append(profiles, bridge.ProfileInfo{ ID: info.ID, Name: info.Name, Path: info.Path, PathExists: pathExists, Created: info.CreatedAt, Temporary: isTemporary, DiskUsage: int64(info.SizeMB * 1024 * 1024), Source: info.Source, ChromeProfileName: info.ChromeProfileName, AccountEmail: info.AccountEmail, AccountName: info.AccountName, HasAccount: info.HasAccount, UseWhen: info.UseWhen, Description: info.Description, }) } sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) return profiles, nil } func (pm *ProfileManager) profileInfo(dirName string) (ProfileDetailedInfo, error) { if err := ValidateProfileName(dirName); err != nil { return ProfileDetailedInfo{}, err } dir := filepath.Join(pm.baseDir, dirName) fi, err := os.Stat(dir) if err != nil { return ProfileDetailedInfo{}, err } size := dirSizeMB(dir) source := "created" if _, err := os.Stat(filepath.Join(dir, ".pinchtab-imported")); err == nil { source = "imported" } chromeProfileName, accountEmail, accountName, hasAccount := readChromeProfileIdentity(dir) meta := readProfileMeta(dir) profileName := meta.Name if profileName == "" { profileName = dirName } changed := false if meta.ID == "" { meta.ID = profileID(profileName) changed = true } if meta.Name == "" { meta.Name = profileName changed = true } if changed { _ = writeProfileMeta(dir, meta) } return ProfileDetailedInfo{ ID: meta.ID, Name: profileName, Path: dir, CreatedAt: fi.ModTime(), SizeMB: size, Source: source, ChromeProfileName: chromeProfileName, AccountEmail: accountEmail, AccountName: accountName, HasAccount: hasAccount, UseWhen: meta.UseWhen, Description: meta.Description, }, nil } func (pm *ProfileManager) Import(name, sourcePath string) error { if err := ValidateProfileName(name); err != nil { return err } pm.mu.Lock() defer pm.mu.Unlock() if _, err := pm.findProfileDirByName(name); err == nil { return fmt.Errorf("profile %q already exists", name) } dest := filepath.Join(pm.baseDir, profileID(name)) if _, err := os.Stat(dest); err == nil { return fmt.Errorf("profile %q already exists", name) } if _, err := os.Stat(filepath.Join(sourcePath, "Default")); err != nil { if _, err2 := os.Stat(filepath.Join(sourcePath, "Preferences")); err2 != nil { return fmt.Errorf("source doesn't look like a Chrome user data dir (no Default/ or Preferences found)") } } srcInfo, err := os.Stat(sourcePath) if err != nil { return fmt.Errorf("source path invalid: %w", err) } if !srcInfo.IsDir() { return fmt.Errorf("source path must be a directory") } slog.Info("importing profile", "name", name, "source", sourcePath) if err := copyDir(sourcePath, dest); err != nil { return fmt.Errorf("copy failed: %w", err) } if err := os.WriteFile(filepath.Join(dest, ".pinchtab-imported"), []byte(sourcePath), 0600); err != nil { slog.Warn("failed to write import marker", "err", err) } return writeProfileMeta(dest, ProfileMeta{ ID: profileID(name), Name: name, }) } func (pm *ProfileManager) ImportWithMeta(name, sourcePath string, meta ProfileMeta) error { if err := pm.Import(name, sourcePath); err != nil { return err } if meta.ID == "" { meta.ID = profileID(name) } if meta.Name == "" { meta.Name = name } dest := filepath.Join(pm.baseDir, profileID(name)) return writeProfileMeta(dest, meta) } func (pm *ProfileManager) Create(name string) error { if err := ValidateProfileName(name); err != nil { return err } pm.mu.Lock() defer pm.mu.Unlock() if _, err := pm.findProfileDirByName(name); err == nil { return fmt.Errorf("profile %q already exists", name) } dest := filepath.Join(pm.baseDir, profileID(name)) if _, err := os.Stat(dest); err == nil { return fmt.Errorf("profile %q already exists", name) } if err := os.MkdirAll(filepath.Join(dest, "Default"), 0755); err != nil { return err } return writeProfileMeta(dest, ProfileMeta{ ID: profileID(name), Name: name, }) } func (pm *ProfileManager) CreateWithMeta(name string, meta ProfileMeta) error { if err := pm.Create(name); err != nil { return err } if meta.ID == "" { meta.ID = profileID(name) } if meta.Name == "" { meta.Name = name } dest := filepath.Join(pm.baseDir, profileID(name)) return writeProfileMeta(dest, meta) } func (pm *ProfileManager) Reset(name string) error { if err := ValidateProfileName(name); err != nil { return err } pm.mu.Lock() defer pm.mu.Unlock() dir, err := pm.findProfileDirByName(name) if err != nil { return err } nukeDirs := []string{ "Default/Sessions", "Default/Session Storage", "Default/Cache", "Default/Code Cache", "Default/GPUCache", "Default/Service Worker", "Default/blob_storage", "ShaderCache", "GrShaderCache", } nukeFiles := []string{ "Default/Cookies", "Default/Cookies-journal", "Default/History", "Default/History-journal", "Default/Visited Links", } for _, d := range nukeDirs { path := filepath.Join(dir, d) if err := os.RemoveAll(path); err != nil { slog.Warn("reset: failed to remove dir", "path", path, "err", err) } } for _, f := range nukeFiles { _ = os.Remove(filepath.Join(dir, f)) } slog.Info("profile reset", "name", name) return nil } func (pm *ProfileManager) Delete(name string) error { if err := ValidateProfileName(name); err != nil { return err } pm.mu.Lock() defer pm.mu.Unlock() dir, err := pm.findProfileDirByName(name) if err != nil { return err } return os.RemoveAll(dir) } func (pm *ProfileManager) RecordAction(profile string, record bridge.ActionRecord) { pm.tracker.Record(profile, record) } func (pm *ProfileManager) Logs(name string, limit int) []bridge.ActionRecord { return pm.tracker.GetLogs(name, limit) } func (pm *ProfileManager) Analytics(name string) bridge.AnalyticsReport { return pm.tracker.Analyze(name) } func dirSizeMB(path string) float64 { var total int64 _ = filepath.WalkDir(path, func(_ string, entry fs.DirEntry, err error) error { if err != nil || entry.IsDir() { return nil } info, err := entry.Info() if err == nil { total += info.Size() } return nil }) return float64(total) / (1024 * 1024) } func (pm *ProfileManager) UpdateMeta(name string, meta map[string]string) error { pm.mu.Lock() defer pm.mu.Unlock() if err := ValidateProfileName(name); err != nil { return err } dir, err := pm.findProfileDirByName(name) if err != nil { return err } existing := readProfileMeta(dir) if existing.Name == "" { existing.Name = name } if useWhen, ok := meta["useWhen"]; ok { existing.UseWhen = useWhen } if description, ok := meta["description"]; ok { existing.Description = description } return writeProfileMeta(dir, existing) } func (pm *ProfileManager) Rename(oldName, newName string) error { if err := ValidateProfileName(oldName); err != nil { return err } if err := ValidateProfileName(newName); err != nil { return err } if oldName == newName { return nil } pm.mu.Lock() defer pm.mu.Unlock() oldDir, err := pm.findProfileDirByName(oldName) if err != nil { return err } if _, err := pm.findProfileDirByName(newName); err == nil { return fmt.Errorf("profile %q already exists", newName) } newDir := filepath.Join(pm.baseDir, profileID(newName)) if _, err := os.Stat(newDir); err == nil { return fmt.Errorf("profile directory for %q already exists", newName) } meta := readProfileMeta(oldDir) meta.ID = profileID(newName) meta.Name = newName if err := writeProfileMeta(oldDir, meta); err != nil { return fmt.Errorf("failed to update profile metadata: %w", err) } if err := os.Rename(oldDir, newDir); err != nil { meta.ID = profileID(oldName) meta.Name = oldName _ = writeProfileMeta(oldDir, meta) return fmt.Errorf("failed to rename profile directory: %w", err) } slog.Info("profile renamed", "from", oldName, "to", newName) return nil } func (pm *ProfileManager) FindByID(id string) (string, error) { pm.mu.RLock() defer pm.mu.RUnlock() entries, err := os.ReadDir(pm.baseDir) if err != nil { return "", err } for _, entry := range entries { if !entry.IsDir() { continue } dir := filepath.Join(pm.baseDir, entry.Name()) meta := readProfileMeta(dir) if meta.ID == id { if meta.Name != "" { return meta.Name, nil } return entry.Name(), nil } if entry.Name() == id && meta.Name != "" { return meta.Name, nil } if meta.ID == "" && profileID(entry.Name()) == id { return entry.Name(), nil } } return "", fmt.Errorf("profile with id %q not found", id) }