mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 04:10:51 +08:00
Introduce `WithSkipPersist` to disable persistence during Manager Update/Register calls, preventing write-back loops caused by redundant file writes. Add corresponding tests and integrate with existing file store and conductor logic.
362 lines
9.0 KiB
Go
362 lines
9.0 KiB
Go
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"
|
|
)
|
|
|
|
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
|
|
type FileTokenStore struct {
|
|
mu sync.Mutex
|
|
dirLock sync.RWMutex
|
|
baseDir string
|
|
}
|
|
|
|
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
|
// TokenStorage implementation embedded in the token record.
|
|
func NewFileTokenStore() *FileTokenStore {
|
|
return &FileTokenStore{}
|
|
}
|
|
|
|
// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
|
|
func (s *FileTokenStore) SetBaseDir(dir string) {
|
|
s.dirLock.Lock()
|
|
s.baseDir = strings.TrimSpace(dir)
|
|
s.dirLock.Unlock()
|
|
}
|
|
|
|
// Save persists token storage and metadata to the resolved auth file path.
|
|
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:
|
|
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
|
|
}
|
|
|
|
// List enumerates all auth JSON files under the configured directory.
|
|
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
|
|
}
|
|
|
|
// Delete removes the auth file.
|
|
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)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: id,
|
|
Provider: provider,
|
|
FileName: id,
|
|
Label: s.labelFor(metadata),
|
|
Status: cliproxyauth.StatusActive,
|
|
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
|
|
}
|
|
|
|
// jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing.
|
|
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
|
|
}
|
|
}
|