| package auth |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io/fs" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strings" |
| "sync" |
| "time" |
|
|
| cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| ) |
|
|
| |
| type FileTokenStore struct { |
| mu sync.Mutex |
| dirLock sync.RWMutex |
| baseDir string |
| } |
|
|
| |
| |
| func NewFileTokenStore() *FileTokenStore { |
| return &FileTokenStore{} |
| } |
|
|
| |
| func (s *FileTokenStore) SetBaseDir(dir string) { |
| s.dirLock.Lock() |
| s.baseDir = strings.TrimSpace(dir) |
| s.dirLock.Unlock() |
| } |
|
|
| |
| func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) { |
| if auth == nil { |
| return "", fmt.Errorf("auth filestore: auth is nil") |
| } |
|
|
| path, err := s.resolveAuthPath(auth) |
| if err != nil { |
| return "", err |
| } |
| if path == "" { |
| return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID) |
| } |
|
|
| if auth.Disabled { |
| if _, statErr := os.Stat(path); os.IsNotExist(statErr) { |
| return "", nil |
| } |
| } |
|
|
| s.mu.Lock() |
| defer s.mu.Unlock() |
|
|
| if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| return "", fmt.Errorf("auth filestore: create dir failed: %w", err) |
| } |
|
|
| switch { |
| case auth.Storage != nil: |
| if err = auth.Storage.SaveTokenToFile(path); err != nil { |
| return "", err |
| } |
| case auth.Metadata != nil: |
| auth.Metadata["disabled"] = auth.Disabled |
| raw, errMarshal := json.Marshal(auth.Metadata) |
| if errMarshal != nil { |
| return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) |
| } |
| if existing, errRead := os.ReadFile(path); errRead == nil { |
| if jsonEqual(existing, raw) { |
| return path, nil |
| } |
| file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) |
| if errOpen != nil { |
| return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen) |
| } |
| if _, errWrite := file.Write(raw); errWrite != nil { |
| _ = file.Close() |
| return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite) |
| } |
| if errClose := file.Close(); errClose != nil { |
| return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose) |
| } |
| return path, nil |
| } else if !os.IsNotExist(errRead) { |
| return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) |
| } |
| if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { |
| return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite) |
| } |
| default: |
| return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) |
| } |
|
|
| if auth.Attributes == nil { |
| auth.Attributes = make(map[string]string) |
| } |
| auth.Attributes["path"] = path |
|
|
| if strings.TrimSpace(auth.FileName) == "" { |
| auth.FileName = auth.ID |
| } |
|
|
| return path, nil |
| } |
|
|
| |
| func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) { |
| dir := s.baseDirSnapshot() |
| if dir == "" { |
| return nil, fmt.Errorf("auth filestore: directory not configured") |
| } |
| entries := make([]*cliproxyauth.Auth, 0) |
| err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { |
| if walkErr != nil { |
| return walkErr |
| } |
| if d.IsDir() { |
| return nil |
| } |
| if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") { |
| return nil |
| } |
| auth, err := s.readAuthFile(path, dir) |
| if err != nil { |
| return nil |
| } |
| if auth != nil { |
| entries = append(entries, auth) |
| } |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| return entries, nil |
| } |
|
|
| |
| func (s *FileTokenStore) Delete(ctx context.Context, id string) error { |
| id = strings.TrimSpace(id) |
| if id == "" { |
| return fmt.Errorf("auth filestore: id is empty") |
| } |
| path, err := s.resolveDeletePath(id) |
| if err != nil { |
| return err |
| } |
| if err = os.Remove(path); err != nil && !os.IsNotExist(err) { |
| return fmt.Errorf("auth filestore: delete failed: %w", err) |
| } |
| return nil |
| } |
|
|
| func (s *FileTokenStore) resolveDeletePath(id string) (string, error) { |
| if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) { |
| return id, nil |
| } |
| dir := s.baseDirSnapshot() |
| if dir == "" { |
| return "", fmt.Errorf("auth filestore: directory not configured") |
| } |
| return filepath.Join(dir, id), nil |
| } |
|
|
| func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { |
| data, err := os.ReadFile(path) |
| if err != nil { |
| return nil, fmt.Errorf("read file: %w", err) |
| } |
| if len(data) == 0 { |
| return nil, nil |
| } |
| metadata := make(map[string]any) |
| if err = json.Unmarshal(data, &metadata); err != nil { |
| return nil, fmt.Errorf("unmarshal auth json: %w", err) |
| } |
| provider, _ := metadata["type"].(string) |
| if provider == "" { |
| provider = "unknown" |
| } |
| if provider == "antigravity" { |
| projectID := "" |
| if pid, ok := metadata["project_id"].(string); ok { |
| projectID = strings.TrimSpace(pid) |
| } |
| if projectID == "" { |
| accessToken := "" |
| if token, ok := metadata["access_token"].(string); ok { |
| accessToken = strings.TrimSpace(token) |
| } |
| if accessToken != "" { |
| fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient) |
| if errFetch == nil && strings.TrimSpace(fetchedProjectID) != "" { |
| metadata["project_id"] = strings.TrimSpace(fetchedProjectID) |
| if raw, errMarshal := json.Marshal(metadata); errMarshal == nil { |
| if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil { |
| _, _ = file.Write(raw) |
| _ = file.Close() |
| } |
| } |
| } |
| } |
| } |
| } |
| info, err := os.Stat(path) |
| if err != nil { |
| return nil, fmt.Errorf("stat file: %w", err) |
| } |
| id := s.idFor(path, baseDir) |
| disabled, _ := metadata["disabled"].(bool) |
| status := cliproxyauth.StatusActive |
| if disabled { |
| status = cliproxyauth.StatusDisabled |
| } |
| auth := &cliproxyauth.Auth{ |
| ID: id, |
| Provider: provider, |
| FileName: id, |
| Label: s.labelFor(metadata), |
| Status: status, |
| Disabled: disabled, |
| Attributes: map[string]string{"path": path}, |
| Metadata: metadata, |
| CreatedAt: info.ModTime(), |
| UpdatedAt: info.ModTime(), |
| LastRefreshedAt: time.Time{}, |
| NextRefreshAfter: time.Time{}, |
| } |
| if email, ok := metadata["email"].(string); ok && email != "" { |
| auth.Attributes["email"] = email |
| } |
| return auth, nil |
| } |
|
|
| func (s *FileTokenStore) idFor(path, baseDir string) string { |
| if baseDir == "" { |
| return path |
| } |
| rel, err := filepath.Rel(baseDir, path) |
| if err != nil { |
| return path |
| } |
| return rel |
| } |
|
|
| func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) { |
| if auth == nil { |
| return "", fmt.Errorf("auth filestore: auth is nil") |
| } |
| if auth.Attributes != nil { |
| if p := strings.TrimSpace(auth.Attributes["path"]); p != "" { |
| return p, nil |
| } |
| } |
| if fileName := strings.TrimSpace(auth.FileName); fileName != "" { |
| if filepath.IsAbs(fileName) { |
| return fileName, nil |
| } |
| if dir := s.baseDirSnapshot(); dir != "" { |
| return filepath.Join(dir, fileName), nil |
| } |
| return fileName, nil |
| } |
| if auth.ID == "" { |
| return "", fmt.Errorf("auth filestore: missing id") |
| } |
| if filepath.IsAbs(auth.ID) { |
| return auth.ID, nil |
| } |
| dir := s.baseDirSnapshot() |
| if dir == "" { |
| return "", fmt.Errorf("auth filestore: directory not configured") |
| } |
| return filepath.Join(dir, auth.ID), nil |
| } |
|
|
| func (s *FileTokenStore) labelFor(metadata map[string]any) string { |
| if metadata == nil { |
| return "" |
| } |
| if v, ok := metadata["label"].(string); ok && v != "" { |
| return v |
| } |
| if v, ok := metadata["email"].(string); ok && v != "" { |
| return v |
| } |
| if project, ok := metadata["project_id"].(string); ok && project != "" { |
| return project |
| } |
| return "" |
| } |
|
|
| func (s *FileTokenStore) baseDirSnapshot() string { |
| s.dirLock.RLock() |
| defer s.dirLock.RUnlock() |
| return s.baseDir |
| } |
|
|
| |
| func jsonEqual(a, b []byte) bool { |
| var objA any |
| var objB any |
| if err := json.Unmarshal(a, &objA); err != nil { |
| return false |
| } |
| if err := json.Unmarshal(b, &objB); err != nil { |
| return false |
| } |
| return deepEqualJSON(objA, objB) |
| } |
|
|
| func deepEqualJSON(a, b any) bool { |
| switch valA := a.(type) { |
| case map[string]any: |
| valB, ok := b.(map[string]any) |
| if !ok || len(valA) != len(valB) { |
| return false |
| } |
| for key, subA := range valA { |
| subB, ok1 := valB[key] |
| if !ok1 || !deepEqualJSON(subA, subB) { |
| return false |
| } |
| } |
| return true |
| case []any: |
| sliceB, ok := b.([]any) |
| if !ok || len(valA) != len(sliceB) { |
| return false |
| } |
| for i := range valA { |
| if !deepEqualJSON(valA[i], sliceB[i]) { |
| return false |
| } |
| } |
| return true |
| case float64: |
| valB, ok := b.(float64) |
| if !ok { |
| return false |
| } |
| return valA == valB |
| case string: |
| valB, ok := b.(string) |
| if !ok { |
| return false |
| } |
| return valA == valB |
| case bool: |
| valB, ok := b.(bool) |
| if !ok { |
| return false |
| } |
| return valA == valB |
| case nil: |
| return b == nil |
| default: |
| return false |
| } |
| } |
|
|