package persistence import ( "context" "encoding/json" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) // AuthRepository implements the ports.AuthRepository interface type AuthRepository struct { authDir string authManager *coreauth.Manager mu sync.RWMutex } // NewAuthRepository creates a new AuthRepository func NewAuthRepository(authDir string, authManager *coreauth.Manager) *AuthRepository { return &AuthRepository{ authDir: authDir, authManager: authManager, } } // List retrieves all authentication files func (r *AuthRepository) List(ctx context.Context) ([]*ports.AuthFile, error) { r.mu.RLock() defer r.mu.RUnlock() // If auth manager is available, use it if r.authManager != nil { return r.listFromManager(ctx) } // Fallback to listing from disk return r.listFromDisk(ctx) } // listFromManager retrieves auth files from the auth manager func (r *AuthRepository) listFromManager(ctx context.Context) ([]*ports.AuthFile, error) { auths := r.authManager.List() files := make([]*ports.AuthFile, 0, len(auths)) for _, auth := range auths { if file := r.mapAuthToFile(auth); file != nil { files = append(files, file) } } // Sort by name sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].FileName) < strings.ToLower(files[j].FileName) }) return files, nil } // listFromDisk retrieves auth files from disk func (r *AuthRepository) listFromDisk(ctx context.Context) ([]*ports.AuthFile, error) { entries, err := os.ReadDir(r.authDir) if err != nil { if os.IsNotExist(err) { return []*ports.AuthFile{}, nil } return nil, errors.Wrap(errors.InternalError, "failed to read auth directory", err) } files := make([]*ports.AuthFile, 0) for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(strings.ToLower(name), ".json") { continue } info, err := e.Info() if err != nil { continue } file := &ports.AuthFile{ ID: name, FileName: name, Size: info.Size(), UpdatedAt: info.ModTime(), } // Read file to get type and email fullPath := filepath.Join(r.authDir, name) if data, err := os.ReadFile(fullPath); err == nil { var metadata map[string]interface{} if err := json.Unmarshal(data, &metadata); err == nil { if t, ok := metadata["type"].(string); ok { file.Provider = t } if email, ok := metadata["email"].(string); ok { file.Email = email } } } files = append(files, file) } return files, nil } // GetByID retrieves an authentication file by its ID func (r *AuthRepository) GetByID(ctx context.Context, id string) (*ports.AuthFile, error) { r.mu.RLock() defer r.mu.RUnlock() if r.authManager != nil { if auth, ok := r.authManager.GetByID(id); ok { return r.mapAuthToFile(auth), nil } } // Try by filename files, err := r.listFromDisk(ctx) if err != nil { return nil, err } for _, file := range files { if file.ID == id || file.FileName == id { return file, nil } } return nil, errors.NewNotFoundError("auth file", id) } // GetByName retrieves an authentication file by its filename func (r *AuthRepository) GetByName(ctx context.Context, name string) (*ports.AuthFile, error) { return r.GetByID(ctx, name) } // Save persists an authentication file func (r *AuthRepository) Save(ctx context.Context, file *ports.AuthFile) error { r.mu.Lock() defer r.mu.Unlock() if file == nil { return errors.New(errors.InvalidInput, "auth file is nil") } if file.FileName == "" { return errors.New(errors.InvalidInput, "auth file name is empty") } // Ensure auth directory exists if err := os.MkdirAll(r.authDir, 0755); err != nil { return errors.Wrap(errors.InternalError, "failed to create auth directory", err) } fullPath := filepath.Join(r.authDir, file.FileName) // Build metadata from file metadata := map[string]interface{}{ "type": file.Provider, } if file.Email != "" { metadata["email"] = file.Email } for k, v := range file.Metadata { metadata[k] = v } // Write file data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return errors.Wrap(errors.InternalError, "failed to marshal auth file", err) } if err := os.WriteFile(fullPath, data, 0600); err != nil { return errors.Wrap(errors.InternalError, "failed to write auth file", err) } // Register with auth manager if available if r.authManager != nil { auth := r.mapFileToAuth(file) if _, err := r.authManager.Register(ctx, auth); err != nil { // Log but don't fail - file is already saved // This could be handled by a logger passed to the repository _ = err } } return nil } // Delete removes an authentication file func (r *AuthRepository) Delete(ctx context.Context, id string) error { r.mu.Lock() defer r.mu.Unlock() fullPath := filepath.Join(r.authDir, id) if !strings.HasSuffix(fullPath, ".json") { fullPath += ".json" } if err := os.Remove(fullPath); err != nil { if os.IsNotExist(err) { return errors.NewNotFoundError("auth file", id) } return errors.Wrap(errors.InternalError, "failed to remove auth file", err) } // Disable in auth manager if available if r.authManager != nil { if auth, ok := r.authManager.GetByID(id); ok { auth.Disabled = true auth.Status = coreauth.StatusDisabled auth.StatusMessage = "removed via management API" auth.UpdatedAt = time.Now() _, _ = r.authManager.Update(ctx, auth) } } return nil } // DeleteAll removes all authentication files func (r *AuthRepository) DeleteAll(ctx context.Context) (int, error) { r.mu.Lock() defer r.mu.Unlock() entries, err := os.ReadDir(r.authDir) if err != nil { if os.IsNotExist(err) { return 0, nil } return 0, errors.Wrap(errors.InternalError, "failed to read auth directory", err) } deleted := 0 for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(strings.ToLower(name), ".json") { continue } fullPath := filepath.Join(r.authDir, name) if err := os.Remove(fullPath); err == nil { deleted++ // Disable in auth manager if r.authManager != nil { if auth, ok := r.authManager.GetByID(name); ok { auth.Disabled = true auth.Status = coreauth.StatusDisabled auth.StatusMessage = "removed via management API" auth.UpdatedAt = time.Now() _, _ = r.authManager.Update(ctx, auth) } } } } return deleted, nil } // Disable marks an authentication file as disabled func (r *AuthRepository) Disable(ctx context.Context, id string, reason string) error { r.mu.Lock() defer r.mu.Unlock() if r.authManager == nil { return errors.ErrAuthManagerUnavailable } auth, ok := r.authManager.GetByID(id) if !ok { return errors.NewNotFoundError("auth file", id) } auth.Disabled = true auth.Status = coreauth.StatusDisabled auth.StatusMessage = reason auth.UpdatedAt = time.Now() _, err := r.authManager.Update(ctx, auth) if err != nil { return errors.Wrap(errors.InternalError, "failed to disable auth file", err) } return nil } // Enable marks an authentication file as enabled func (r *AuthRepository) Enable(ctx context.Context, id string) error { r.mu.Lock() defer r.mu.Unlock() if r.authManager == nil { return errors.ErrAuthManagerUnavailable } auth, ok := r.authManager.GetByID(id) if !ok { return errors.NewNotFoundError("auth file", id) } auth.Disabled = false auth.Status = coreauth.StatusActive auth.StatusMessage = "" auth.UpdatedAt = time.Now() _, err := r.authManager.Update(ctx, auth) if err != nil { return errors.Wrap(errors.InternalError, "failed to enable auth file", err) } return nil } // GetAuthDir returns the authentication directory path func (r *AuthRepository) GetAuthDir() string { r.mu.RLock() defer r.mu.RUnlock() return r.authDir } // mapAuthToFile maps a coreauth.Auth to an ports.AuthFile func (r *AuthRepository) mapAuthToFile(auth *coreauth.Auth) *ports.AuthFile { if auth == nil { return nil } auth.EnsureIndex() // Skip runtime-only disabled auths runtimeOnly := r.isRuntimeOnlyAuth(auth) if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) { return nil } file := &ports.AuthFile{ ID: auth.ID, Provider: strings.TrimSpace(auth.Provider), FileName: auth.FileName, Label: auth.Label, Status: string(auth.Status), StatusMessage: auth.StatusMessage, Disabled: auth.Disabled, Unavailable: auth.Unavailable, RuntimeOnly: runtimeOnly, CreatedAt: auth.CreatedAt, UpdatedAt: auth.UpdatedAt, LastRefreshedAt: auth.LastRefreshedAt, Metadata: make(map[string]interface{}), Attributes: auth.Attributes, } // Copy metadata for k, v := range auth.Metadata { file.Metadata[k] = v } // Extract email if auth.Metadata != nil { if email, ok := auth.Metadata["email"].(string); ok { file.Email = email } } if file.Email == "" && auth.Attributes != nil { if email := auth.Attributes["email"]; email != "" { file.Email = email } } // Get file info if path exists if path := r.authPath(auth); path != "" { file.Path = path if info, err := os.Stat(path); err == nil { file.Size = info.Size() if file.UpdatedAt.IsZero() { file.UpdatedAt = info.ModTime() } } } return file } // mapFileToAuth maps an ports.AuthFile to a coreauth.Auth func (r *AuthRepository) mapFileToAuth(file *ports.AuthFile) *coreauth.Auth { if file == nil { return nil } return &coreauth.Auth{ ID: file.ID, Provider: file.Provider, FileName: file.FileName, Label: file.Label, Status: coreauth.Status(file.Status), Disabled: file.Disabled, Unavailable: file.Unavailable, Metadata: file.Metadata, Attributes: file.Attributes, CreatedAt: file.CreatedAt, UpdatedAt: file.UpdatedAt, } } // authPath extracts the path from auth attributes func (r *AuthRepository) authPath(auth *coreauth.Auth) string { if auth == nil || len(auth.Attributes) == 0 { return "" } return strings.TrimSpace(auth.Attributes["path"]) } // isRuntimeOnlyAuth checks if an auth is runtime-only func (r *AuthRepository) isRuntimeOnlyAuth(auth *coreauth.Auth) bool { if auth == nil || len(auth.Attributes) == 0 { return false } return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") } // SetAuthManager updates the auth manager func (r *AuthRepository) SetAuthManager(manager *coreauth.Manager) { r.mu.Lock() defer r.mu.Unlock() r.authManager = manager } // Ensure AuthRepository implements the interface var _ ports.AuthRepository = (*AuthRepository)(nil)