Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
1892
backend/internal/store/gitstore.go
Normal file
1892
backend/internal/store/gitstore.go
Normal file
File diff suppressed because it is too large
Load diff
1852
backend/internal/store/gitstore_test.go
Normal file
1852
backend/internal/store/gitstore_test.go
Normal file
File diff suppressed because it is too large
Load diff
644
backend/internal/store/objectstore.go
Normal file
644
backend/internal/store/objectstore.go
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
objectStoreConfigKey = "config/config.yaml"
|
||||
objectStoreAuthPrefix = "auths"
|
||||
)
|
||||
|
||||
// ObjectStoreConfig captures configuration for the object storage-backed token store.
|
||||
type ObjectStoreConfig struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
Prefix string
|
||||
LocalRoot string
|
||||
UseSSL bool
|
||||
PathStyle bool
|
||||
}
|
||||
|
||||
// ObjectTokenStore persists configuration and authentication metadata using an S3-compatible object storage backend.
|
||||
// Files are mirrored to a local workspace so existing file-based flows continue to operate.
|
||||
type ObjectTokenStore struct {
|
||||
client *minio.Client
|
||||
cfg ObjectStoreConfig
|
||||
spoolRoot string
|
||||
configPath string
|
||||
authDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewObjectTokenStore initializes an object storage backed token store.
|
||||
func NewObjectTokenStore(cfg ObjectStoreConfig) (*ObjectTokenStore, error) {
|
||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||||
cfg.Bucket = strings.TrimSpace(cfg.Bucket)
|
||||
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
|
||||
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
|
||||
cfg.Prefix = strings.Trim(cfg.Prefix, "/")
|
||||
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, fmt.Errorf("object store: endpoint is required")
|
||||
}
|
||||
if cfg.Bucket == "" {
|
||||
return nil, fmt.Errorf("object store: bucket is required")
|
||||
}
|
||||
if cfg.AccessKey == "" {
|
||||
return nil, fmt.Errorf("object store: access key is required")
|
||||
}
|
||||
if cfg.SecretKey == "" {
|
||||
return nil, fmt.Errorf("object store: secret key is required")
|
||||
}
|
||||
|
||||
root := strings.TrimSpace(cfg.LocalRoot)
|
||||
if root == "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
root = filepath.Join(cwd, "objectstore")
|
||||
} else {
|
||||
root = filepath.Join(os.TempDir(), "objectstore")
|
||||
}
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("object store: resolve spool directory: %w", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(absRoot, "config")
|
||||
authDir := filepath.Join(absRoot, "auths")
|
||||
|
||||
if err = os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("object store: create config directory: %w", err)
|
||||
}
|
||||
if err = os.MkdirAll(authDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("object store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
options := &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
}
|
||||
if cfg.PathStyle {
|
||||
options.BucketLookup = minio.BucketLookupPath
|
||||
}
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("object store: create client: %w", err)
|
||||
}
|
||||
|
||||
return &ObjectTokenStore{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
spoolRoot: absRoot,
|
||||
configPath: filepath.Join(configDir, "config.yaml"),
|
||||
authDir: authDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
|
||||
// the object store controls its own workspace.
|
||||
func (s *ObjectTokenStore) SetBaseDir(string) {}
|
||||
|
||||
// ConfigPath returns the managed configuration file path inside the spool directory.
|
||||
func (s *ObjectTokenStore) ConfigPath() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.configPath
|
||||
}
|
||||
|
||||
// AuthDir returns the local directory containing mirrored auth files.
|
||||
func (s *ObjectTokenStore) AuthDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.authDir
|
||||
}
|
||||
|
||||
// Bootstrap ensures the target bucket exists and synchronizes data from the object storage backend.
|
||||
func (s *ObjectTokenStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("object store: not initialized")
|
||||
}
|
||||
if err := s.ensureBucket(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncConfigFromBucket(ctx, exampleConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncAuthFromBucket(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists authentication metadata to disk and uploads it to the object storage backend.
|
||||
func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("object store: auth is nil")
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {
|
||||
return "", fmt.Errorf("object store: %w", errWeight)
|
||||
}
|
||||
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("object store: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("object store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case auth.Storage != nil:
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["disabled"] = auth.Disabled
|
||||
if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
|
||||
setter.SetMetadata(auth.Metadata)
|
||||
}
|
||||
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("object store: marshal metadata: %w", errMarshal)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
return path, nil
|
||||
}
|
||||
} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("object store: read existing metadata: %w", errRead)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
||||
return "", fmt.Errorf("object store: write temp auth file: %w", errWrite)
|
||||
}
|
||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
||||
return "", fmt.Errorf("object store: rename auth file: %w", errRename)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("object store: nothing to persist for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes[cliproxyauth.AttributePath] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceObjectStore
|
||||
|
||||
if strings.TrimSpace(auth.FileName) == "" {
|
||||
auth.FileName = auth.ID
|
||||
}
|
||||
|
||||
if err = s.uploadAuth(ctx, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates auth JSON files from the mirrored workspace.
|
||||
func (s *ObjectTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
|
||||
dir := strings.TrimSpace(s.AuthDir())
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("object store: auth directory not configured")
|
||||
}
|
||||
entries := make([]*cliproxyauth.Auth, 0, 32)
|
||||
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 {
|
||||
log.WithError(err).Warnf("object store: skip auth %s", path)
|
||||
return nil
|
||||
}
|
||||
if auth != nil {
|
||||
entries = append(entries, auth)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("object store: walk auth directory: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Delete removes an auth file locally and remotely.
|
||||
func (s *ObjectTokenStore) Delete(ctx context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("object store: id is empty")
|
||||
}
|
||||
path, err := s.resolveDeletePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("object store: delete auth file: %w", err)
|
||||
}
|
||||
if err = s.deleteAuthObject(ctx, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistAuthFiles uploads the provided auth files to the object storage backend.
|
||||
func (s *ObjectTokenStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, p := range paths {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
abs := trimmed
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(s.authDir, trimmed)
|
||||
}
|
||||
if err := s.uploadAuth(ctx, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistConfig uploads the local configuration file to the object storage backend.
|
||||
func (s *ObjectTokenStore) PersistConfig(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteObject(ctx, objectStoreConfigKey)
|
||||
}
|
||||
return fmt.Errorf("object store: read config file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteObject(ctx, objectStoreConfigKey)
|
||||
}
|
||||
return s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml")
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) ensureBucket(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("object store: check bucket: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if err = s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{Region: s.cfg.Region}); err != nil {
|
||||
return fmt.Errorf("object store: create bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) syncConfigFromBucket(ctx context.Context, example string) error {
|
||||
key := s.prefixedKey(objectStoreConfigKey)
|
||||
_, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{})
|
||||
switch {
|
||||
case err == nil:
|
||||
object, errGet := s.client.GetObject(ctx, s.cfg.Bucket, key, minio.GetObjectOptions{})
|
||||
if errGet != nil {
|
||||
return fmt.Errorf("object store: fetch config: %w", errGet)
|
||||
}
|
||||
defer object.Close()
|
||||
data, errRead := io.ReadAll(object)
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("object store: read config: %w", errRead)
|
||||
}
|
||||
if errWrite := os.WriteFile(s.configPath, normalizeLineEndingsBytes(data), 0o600); errWrite != nil {
|
||||
return fmt.Errorf("object store: write config: %w", errWrite)
|
||||
}
|
||||
case isObjectNotFound(err):
|
||||
if _, statErr := os.Stat(s.configPath); errors.Is(statErr, fs.ErrNotExist) {
|
||||
if example != "" {
|
||||
if errCopy := misc.CopyConfigTemplate(example, s.configPath); errCopy != nil {
|
||||
return fmt.Errorf("object store: copy example config: %w", errCopy)
|
||||
}
|
||||
} else {
|
||||
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
|
||||
return fmt.Errorf("object store: prepare config directory: %w", errCreate)
|
||||
}
|
||||
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
|
||||
return fmt.Errorf("object store: create empty config: %w", errWrite)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, errRead := os.ReadFile(s.configPath)
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("object store: read local config: %w", errRead)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if errPut := s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml"); errPut != nil {
|
||||
return errPut
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("object store: stat config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) syncAuthFromBucket(ctx context.Context) error {
|
||||
// NOTE: We intentionally do NOT use os.RemoveAll here.
|
||||
// Wiping the directory triggers file watcher delete events, which then
|
||||
// propagate deletions to the remote object store (race condition).
|
||||
// Instead, we just ensure the directory exists and overwrite files incrementally.
|
||||
if err := os.MkdirAll(s.authDir, 0o700); err != nil {
|
||||
return fmt.Errorf("object store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
prefix := s.prefixedKey(objectStoreAuthPrefix + "/")
|
||||
objectCh := s.client.ListObjects(ctx, s.cfg.Bucket, minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
})
|
||||
for object := range objectCh {
|
||||
if object.Err != nil {
|
||||
return fmt.Errorf("object store: list auth objects: %w", object.Err)
|
||||
}
|
||||
rel := strings.TrimPrefix(object.Key, prefix)
|
||||
if rel == "" || strings.HasSuffix(rel, "/") {
|
||||
continue
|
||||
}
|
||||
relPath := filepath.FromSlash(rel)
|
||||
if filepath.IsAbs(relPath) {
|
||||
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
|
||||
continue
|
||||
}
|
||||
cleanRel := filepath.Clean(relPath)
|
||||
if cleanRel == "." || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) {
|
||||
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
|
||||
continue
|
||||
}
|
||||
local := filepath.Join(s.authDir, cleanRel)
|
||||
if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil {
|
||||
return fmt.Errorf("object store: prepare auth subdir: %w", err)
|
||||
}
|
||||
reader, errGet := s.client.GetObject(ctx, s.cfg.Bucket, object.Key, minio.GetObjectOptions{})
|
||||
if errGet != nil {
|
||||
return fmt.Errorf("object store: download auth %s: %w", object.Key, errGet)
|
||||
}
|
||||
data, errRead := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("object store: read auth %s: %w", object.Key, errRead)
|
||||
}
|
||||
if errWrite := os.WriteFile(local, data, 0o600); errWrite != nil {
|
||||
return fmt.Errorf("object store: write auth %s: %w", local, errWrite)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) uploadAuth(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(s.authDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("object store: resolve auth relative path: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteAuthObject(ctx, path)
|
||||
}
|
||||
return fmt.Errorf("object store: read auth file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteAuthObject(ctx, path)
|
||||
}
|
||||
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
|
||||
return s.putObject(ctx, key, data, "application/json")
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) deleteAuthObject(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(s.authDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("object store: resolve auth relative path: %w", err)
|
||||
}
|
||||
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
|
||||
return s.deleteObject(ctx, key)
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) putObject(ctx context.Context, key string, data []byte, contentType string) error {
|
||||
if len(data) == 0 {
|
||||
return s.deleteObject(ctx, key)
|
||||
}
|
||||
fullKey := s.prefixedKey(key)
|
||||
reader := bytes.NewReader(data)
|
||||
_, err := s.client.PutObject(ctx, s.cfg.Bucket, fullKey, reader, int64(len(data)), minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("object store: put object %s: %w", fullKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) deleteObject(ctx context.Context, key string) error {
|
||||
fullKey := s.prefixedKey(key)
|
||||
err := s.client.RemoveObject(ctx, s.cfg.Bucket, fullKey, minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
if isObjectNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("object store: delete object %s: %w", fullKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) prefixedKey(key string) string {
|
||||
key = strings.TrimLeft(key, "/")
|
||||
if s.cfg.Prefix == "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimLeft(s.cfg.Prefix+"/"+key, "/")
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("object store: auth is nil")
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if path := strings.TrimSpace(auth.Attributes["path"]); path != "" {
|
||||
if filepath.IsAbs(path) {
|
||||
return path, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, path), nil
|
||||
}
|
||||
}
|
||||
fileName := strings.TrimSpace(auth.FileName)
|
||||
if fileName == "" {
|
||||
fileName = strings.TrimSpace(auth.ID)
|
||||
}
|
||||
if fileName == "" {
|
||||
return "", fmt.Errorf("object store: auth %s missing filename", auth.ID)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(fileName), ".json") {
|
||||
fileName += ".json"
|
||||
}
|
||||
return filepath.Join(s.authDir, fileName), nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) resolveDeletePath(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("object store: id is empty")
|
||||
}
|
||||
// Absolute paths are honored as-is; callers must ensure they point inside the mirror.
|
||||
if filepath.IsAbs(id) {
|
||||
return id, nil
|
||||
}
|
||||
// Treat any non-absolute id (including nested like "team/foo") as relative to the mirror authDir.
|
||||
// Normalize separators and guard against path traversal.
|
||||
clean := filepath.Clean(filepath.FromSlash(id))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("object store: invalid auth identifier %s", id)
|
||||
}
|
||||
// Ensure .json suffix.
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".json") {
|
||||
clean += ".json"
|
||||
}
|
||||
return filepath.Join(s.authDir, clean), nil
|
||||
}
|
||||
|
||||
func (s *ObjectTokenStore) 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)
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil {
|
||||
return nil, errWeight
|
||||
}
|
||||
provider := strings.TrimSpace(valueAsString(metadata["type"]))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat auth file: %w", err)
|
||||
}
|
||||
rel, errRel := filepath.Rel(baseDir, path)
|
||||
if errRel != nil {
|
||||
rel = filepath.Base(path)
|
||||
}
|
||||
rel = normalizeAuthID(rel)
|
||||
attr := map[string]string{
|
||||
cliproxyauth.AttributePath: path,
|
||||
cliproxyauth.AttributeSourceBackend: cliproxyauth.AuthSourceObjectStore,
|
||||
}
|
||||
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
|
||||
attr["email"] = email
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: rel,
|
||||
Provider: provider,
|
||||
FileName: rel,
|
||||
Label: labelFor(metadata),
|
||||
Status: cliproxyauth.StatusActive,
|
||||
Attributes: attr,
|
||||
Metadata: metadata,
|
||||
CreatedAt: info.ModTime(),
|
||||
UpdatedAt: info.ModTime(),
|
||||
LastRefreshedAt: time.Time{},
|
||||
NextRefreshAfter: time.Time{},
|
||||
}
|
||||
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
|
||||
if disabled, ok := metadata["disabled"].(bool); ok && disabled {
|
||||
auth.Disabled = true
|
||||
auth.Status = cliproxyauth.StatusDisabled
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func normalizeLineEndingsBytes(data []byte) []byte {
|
||||
replaced := bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
|
||||
return bytes.ReplaceAll(replaced, []byte{'\r'}, []byte{'\n'})
|
||||
}
|
||||
|
||||
func isObjectNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
resp := minio.ToErrorResponse(err)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return true
|
||||
}
|
||||
switch resp.Code {
|
||||
case "NoSuchKey", "NotFound", "NoSuchBucket":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
193
backend/internal/store/postgres_cooldown_store.go
Normal file
193
backend/internal/store/postgres_cooldown_store.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
var _ cliproxyauth.CooldownStateStoreProvider = (*PostgresStore)(nil)
|
||||
var _ cliproxyauth.CooldownStateStore = (*postgresCooldownStateStore)(nil)
|
||||
|
||||
type postgresCooldownStateKey struct {
|
||||
authID string
|
||||
model string
|
||||
}
|
||||
|
||||
type postgresCooldownStateRecord struct {
|
||||
key postgresCooldownStateKey
|
||||
content []byte
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
type postgresCooldownStateVersion struct {
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
type postgresCooldownStateStore struct {
|
||||
store *PostgresStore
|
||||
mu sync.Mutex
|
||||
previous map[postgresCooldownStateKey]postgresCooldownStateVersion
|
||||
}
|
||||
|
||||
// CooldownStateStore returns the PostgreSQL-backed runtime cooldown store.
|
||||
func (s *PostgresStore) CooldownStateStore() cliproxyauth.CooldownStateStore {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cooldownStore
|
||||
}
|
||||
|
||||
func (s *postgresCooldownStateStore) Load(ctx context.Context) (records []cliproxyauth.CooldownStateRecord, err error) {
|
||||
if s == nil || s.store == nil || s.store.db == nil {
|
||||
return nil, fmt.Errorf("postgres cooldown store: not initialized")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
table := s.store.fullTableName(s.store.cfg.CooldownTable)
|
||||
query := fmt.Sprintf("SELECT content, updated_at FROM %s WHERE deleted = FALSE", table)
|
||||
rows, errQuery := s.store.db.QueryContext(ctx, query)
|
||||
if errQuery != nil {
|
||||
return nil, fmt.Errorf("postgres cooldown store: load state: %w", errQuery)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := rows.Close(); errClose != nil {
|
||||
err = errors.Join(err, fmt.Errorf("postgres cooldown store: close state rows: %w", errClose))
|
||||
}
|
||||
}()
|
||||
|
||||
records = make([]cliproxyauth.CooldownStateRecord, 0)
|
||||
previous := make(map[postgresCooldownStateKey]postgresCooldownStateVersion)
|
||||
for rows.Next() {
|
||||
var content []byte
|
||||
var updatedAt time.Time
|
||||
if errScan := rows.Scan(&content, &updatedAt); errScan != nil {
|
||||
return nil, fmt.Errorf("postgres cooldown store: scan state: %w", errScan)
|
||||
}
|
||||
var record cliproxyauth.CooldownStateRecord
|
||||
if errUnmarshal := json.Unmarshal(content, &record); errUnmarshal != nil {
|
||||
return nil, fmt.Errorf("postgres cooldown store: decode state: %w", errUnmarshal)
|
||||
}
|
||||
key := cooldownStateKey(record)
|
||||
if key.authID == "" {
|
||||
return nil, fmt.Errorf("postgres cooldown store: decoded state has empty auth ID")
|
||||
}
|
||||
records = append(records, record)
|
||||
previous[key] = postgresCooldownStateVersion{updatedAt: updatedAt}
|
||||
}
|
||||
if errRows := rows.Err(); errRows != nil {
|
||||
return nil, fmt.Errorf("postgres cooldown store: iterate state: %w", errRows)
|
||||
}
|
||||
s.previous = previous
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *postgresCooldownStateStore) Save(ctx context.Context, records []cliproxyauth.CooldownStateRecord) error {
|
||||
if s == nil || s.store == nil || s.store.db == nil {
|
||||
return fmt.Errorf("postgres cooldown store: not initialized")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
now := normalizePostgresCooldownTime(time.Now(), time.Time{})
|
||||
current := make(map[postgresCooldownStateKey]postgresCooldownStateVersion, len(records))
|
||||
encoded := make([]postgresCooldownStateRecord, 0, len(records))
|
||||
for i := range records {
|
||||
record := records[i]
|
||||
key := cooldownStateKey(record)
|
||||
if key.authID == "" {
|
||||
return fmt.Errorf("postgres cooldown store: state has empty auth ID")
|
||||
}
|
||||
record.UpdatedAt = normalizePostgresCooldownTime(record.UpdatedAt, now)
|
||||
content, errMarshal := json.Marshal(record)
|
||||
if errMarshal != nil {
|
||||
return fmt.Errorf("postgres cooldown store: encode state for %q: %w", key.authID, errMarshal)
|
||||
}
|
||||
current[key] = postgresCooldownStateVersion{updatedAt: record.UpdatedAt}
|
||||
encoded = append(encoded, postgresCooldownStateRecord{key: key, content: content, updatedAt: record.UpdatedAt})
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, errBegin := s.store.db.BeginTx(ctx, nil)
|
||||
if errBegin != nil {
|
||||
return fmt.Errorf("postgres cooldown store: begin save: %w", errBegin)
|
||||
}
|
||||
table := s.store.fullTableName(s.store.cfg.CooldownTable)
|
||||
upsertQuery := fmt.Sprintf(`
|
||||
INSERT INTO %s AS target (auth_id, model, content, deleted, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, FALSE, NOW(), $4)
|
||||
ON CONFLICT (auth_id, model) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
deleted = FALSE,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE target.updated_at <= EXCLUDED.updated_at
|
||||
`, table)
|
||||
for i := range encoded {
|
||||
record := encoded[i]
|
||||
if _, errExec := tx.ExecContext(ctx, upsertQuery, record.key.authID, record.key.model, record.content, record.updatedAt); errExec != nil {
|
||||
return rollbackPostgresCooldownTransaction(tx, fmt.Errorf("postgres cooldown store: save state for %q: %w", record.key.authID, errExec))
|
||||
}
|
||||
}
|
||||
deleteQuery := fmt.Sprintf(`
|
||||
INSERT INTO %s AS target (auth_id, model, content, deleted, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, TRUE, NOW(), $4)
|
||||
ON CONFLICT (auth_id, model) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
deleted = TRUE,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE NOT target.deleted AND target.updated_at <= $5
|
||||
`, table)
|
||||
for key, previous := range s.previous {
|
||||
if _, ok := current[key]; ok {
|
||||
continue
|
||||
}
|
||||
deletedAt := now
|
||||
if !deletedAt.After(previous.updatedAt) {
|
||||
deletedAt = previous.updatedAt.Add(time.Microsecond)
|
||||
}
|
||||
if _, errExec := tx.ExecContext(ctx, deleteQuery, key.authID, key.model, []byte(`{}`), deletedAt, previous.updatedAt); errExec != nil {
|
||||
return rollbackPostgresCooldownTransaction(tx, fmt.Errorf("postgres cooldown store: clear state for %q: %w", key.authID, errExec))
|
||||
}
|
||||
}
|
||||
if errCommit := tx.Commit(); errCommit != nil {
|
||||
return fmt.Errorf("postgres cooldown store: commit save: %w", errCommit)
|
||||
}
|
||||
s.previous = current
|
||||
return nil
|
||||
}
|
||||
|
||||
func cooldownStateKey(record cliproxyauth.CooldownStateRecord) postgresCooldownStateKey {
|
||||
return postgresCooldownStateKey{
|
||||
authID: strings.TrimSpace(record.AuthID),
|
||||
model: strings.TrimSpace(record.Model),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePostgresCooldownTime(value, fallback time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
value = fallback
|
||||
}
|
||||
return value.UTC().Truncate(time.Microsecond)
|
||||
}
|
||||
|
||||
func rollbackPostgresCooldownTransaction(tx *sql.Tx, operationErr error) error {
|
||||
if errRollback := tx.Rollback(); errRollback != nil && !errors.Is(errRollback, sql.ErrTxDone) {
|
||||
return errors.Join(operationErr, fmt.Errorf("postgres cooldown store: rollback save: %w", errRollback))
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
297
backend/internal/store/postgres_cooldown_store_test.go
Normal file
297
backend/internal/store/postgres_cooldown_store_test.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
var cooldownTestDriverID atomic.Uint64
|
||||
|
||||
type cooldownTestDriver struct {
|
||||
state *cooldownTestState
|
||||
}
|
||||
|
||||
type cooldownTestState struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]cooldownTestRow
|
||||
queries []string
|
||||
}
|
||||
|
||||
type cooldownTestRow struct {
|
||||
content []byte
|
||||
deleted bool
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
type cooldownTestConn struct {
|
||||
state *cooldownTestState
|
||||
}
|
||||
|
||||
type cooldownTestTx struct{}
|
||||
|
||||
type cooldownTestRows struct {
|
||||
rows []cooldownTestRow
|
||||
index int
|
||||
}
|
||||
|
||||
func (d *cooldownTestDriver) Open(string) (driver.Conn, error) {
|
||||
return &cooldownTestConn{state: d.state}, nil
|
||||
}
|
||||
|
||||
func (c *cooldownTestConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("prepare is not supported")
|
||||
}
|
||||
|
||||
func (c *cooldownTestConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cooldownTestConn) Begin() (driver.Tx, error) {
|
||||
return &cooldownTestTx{}, nil
|
||||
}
|
||||
|
||||
func (c *cooldownTestConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
c.state.queries = append(c.state.queries, query)
|
||||
if !strings.Contains(query, "INSERT INTO") || (len(args) != 4 && len(args) != 5) {
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
authID, okAuthID := args[0].Value.(string)
|
||||
model, okModel := args[1].Value.(string)
|
||||
content, okContent := args[2].Value.([]byte)
|
||||
updatedAt, okUpdatedAt := args[3].Value.(time.Time)
|
||||
if !okAuthID || !okModel || !okContent || !okUpdatedAt {
|
||||
return nil, errors.New("invalid cooldown query arguments")
|
||||
}
|
||||
key := authID + "\x00" + model
|
||||
current, exists := c.state.rows[key]
|
||||
if len(args) == 4 {
|
||||
if !exists || !current.updatedAt.After(updatedAt) {
|
||||
c.state.rows[key] = cooldownTestRow{content: append([]byte(nil), content...), updatedAt: updatedAt}
|
||||
}
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
observedAt, okObservedAt := args[4].Value.(time.Time)
|
||||
if !okObservedAt {
|
||||
return nil, errors.New("invalid cooldown delete version")
|
||||
}
|
||||
if !exists || (!current.deleted && !current.updatedAt.After(observedAt)) {
|
||||
c.state.rows[key] = cooldownTestRow{content: append([]byte(nil), content...), deleted: true, updatedAt: updatedAt}
|
||||
}
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
|
||||
func (c *cooldownTestConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
c.state.queries = append(c.state.queries, query)
|
||||
rows := make([]cooldownTestRow, 0, len(c.state.rows))
|
||||
for _, row := range c.state.rows {
|
||||
if !row.deleted {
|
||||
row.content = append([]byte(nil), row.content...)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
return &cooldownTestRows{rows: rows}, nil
|
||||
}
|
||||
|
||||
func (*cooldownTestTx) Commit() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*cooldownTestTx) Rollback() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cooldownTestRows) Columns() []string {
|
||||
return []string{"content", "updated_at"}
|
||||
}
|
||||
|
||||
func (r *cooldownTestRows) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cooldownTestRows) Next(dest []driver.Value) error {
|
||||
if r.index >= len(r.rows) {
|
||||
return io.EOF
|
||||
}
|
||||
dest[0] = r.rows[r.index].content
|
||||
dest[1] = r.rows[r.index].updatedAt
|
||||
r.index++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPostgresCooldownStateStore_SaveLoad(t *testing.T) {
|
||||
state := &cooldownTestState{rows: make(map[string]cooldownTestRow)}
|
||||
driverName := fmt.Sprintf("cliproxy_postgres_cooldown_test_%d", cooldownTestDriverID.Add(1))
|
||||
sql.Register(driverName, &cooldownTestDriver{state: state})
|
||||
db, errOpen := sql.Open(driverName, "")
|
||||
if errOpen != nil {
|
||||
t.Fatalf("sql.Open() error = %v", errOpen)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if errClose := db.Close(); errClose != nil {
|
||||
t.Errorf("db.Close() error = %v", errClose)
|
||||
}
|
||||
})
|
||||
|
||||
postgresStore := &PostgresStore{
|
||||
db: db,
|
||||
cfg: PostgresStoreConfig{
|
||||
ConfigTable: defaultConfigTable,
|
||||
AuthTable: defaultAuthTable,
|
||||
CooldownTable: defaultCooldownTable,
|
||||
},
|
||||
}
|
||||
cooldownStore := &postgresCooldownStateStore{store: postgresStore}
|
||||
postgresStore.cooldownStore = cooldownStore
|
||||
|
||||
if errSchema := postgresStore.EnsureSchema(context.Background()); errSchema != nil {
|
||||
t.Fatalf("EnsureSchema() error = %v", errSchema)
|
||||
}
|
||||
if got := postgresStore.CooldownStateStore(); got != cooldownStore {
|
||||
t.Fatalf("CooldownStateStore() = %T, want configured PostgreSQL store", got)
|
||||
}
|
||||
|
||||
nextRetry := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC)
|
||||
records := []cliproxyauth.CooldownStateRecord{
|
||||
{
|
||||
Provider: "codex",
|
||||
AuthID: "account-1",
|
||||
Model: "gpt-test",
|
||||
Status: string(cliproxyauth.StatusError),
|
||||
NextRetryAfter: nextRetry,
|
||||
Reason: "rate limited",
|
||||
UpdatedAt: nextRetry.Add(-time.Minute),
|
||||
},
|
||||
}
|
||||
if errSave := cooldownStore.Save(context.Background(), records); errSave != nil {
|
||||
t.Fatalf("Save() error = %v", errSave)
|
||||
}
|
||||
loaded, errLoad := cooldownStore.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("Load() error = %v", errLoad)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded, records) {
|
||||
t.Fatalf("Load() = %#v, want %#v", loaded, records)
|
||||
}
|
||||
|
||||
zeroTimeRecord := cliproxyauth.CooldownStateRecord{AuthID: "account-2", Model: "gpt-test"}
|
||||
if errSave := cooldownStore.Save(context.Background(), []cliproxyauth.CooldownStateRecord{zeroTimeRecord}); errSave != nil {
|
||||
t.Fatalf("Save() with zero UpdatedAt error = %v", errSave)
|
||||
}
|
||||
loaded, errLoad = cooldownStore.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("Load() after zero UpdatedAt error = %v", errLoad)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].UpdatedAt.IsZero() {
|
||||
t.Fatalf("Load() did not persist a normalized UpdatedAt: %#v", loaded)
|
||||
}
|
||||
|
||||
if errSave := cooldownStore.Save(context.Background(), nil); errSave != nil {
|
||||
t.Fatalf("Save(nil) error = %v", errSave)
|
||||
}
|
||||
loaded, errLoad = cooldownStore.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("Load() after Save(nil) error = %v", errLoad)
|
||||
}
|
||||
if len(loaded) != 0 {
|
||||
t.Fatalf("Load() after Save(nil) returned %d records, want 0", len(loaded))
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
queries := strings.Join(state.queries, "\n")
|
||||
state.mu.Unlock()
|
||||
if !strings.Contains(queries, `CREATE TABLE IF NOT EXISTS "cooldown_store"`) {
|
||||
t.Fatalf("EnsureSchema() did not create cooldown table; queries:\n%s", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresCooldownStateStore_MergesConcurrentInstances(t *testing.T) {
|
||||
state := &cooldownTestState{rows: make(map[string]cooldownTestRow)}
|
||||
driverName := fmt.Sprintf("cliproxy_postgres_cooldown_merge_test_%d", cooldownTestDriverID.Add(1))
|
||||
sql.Register(driverName, &cooldownTestDriver{state: state})
|
||||
db, errOpen := sql.Open(driverName, "")
|
||||
if errOpen != nil {
|
||||
t.Fatalf("sql.Open() error = %v", errOpen)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if errClose := db.Close(); errClose != nil {
|
||||
t.Errorf("db.Close() error = %v", errClose)
|
||||
}
|
||||
})
|
||||
postgresStore := &PostgresStore{
|
||||
db: db,
|
||||
cfg: PostgresStoreConfig{CooldownTable: defaultCooldownTable},
|
||||
}
|
||||
storeA := &postgresCooldownStateStore{store: postgresStore}
|
||||
storeB := &postgresCooldownStateStore{store: postgresStore}
|
||||
staleStore := &postgresCooldownStateStore{store: postgresStore}
|
||||
|
||||
for _, cooldownStore := range []*postgresCooldownStateStore{storeA, storeB} {
|
||||
if _, errLoad := cooldownStore.Load(context.Background()); errLoad != nil {
|
||||
t.Fatalf("initial Load() error = %v", errLoad)
|
||||
}
|
||||
}
|
||||
updatedAt := time.Now().UTC().Add(-time.Minute)
|
||||
recordA := cliproxyauth.CooldownStateRecord{AuthID: "account-a", Model: "model-a", UpdatedAt: updatedAt}
|
||||
recordB := cliproxyauth.CooldownStateRecord{AuthID: "account-b", Model: "model-b", UpdatedAt: updatedAt}
|
||||
if errSave := storeA.Save(context.Background(), []cliproxyauth.CooldownStateRecord{recordA}); errSave != nil {
|
||||
t.Fatalf("storeA.Save() error = %v", errSave)
|
||||
}
|
||||
if errSave := storeB.Save(context.Background(), []cliproxyauth.CooldownStateRecord{recordB}); errSave != nil {
|
||||
t.Fatalf("storeB.Save() error = %v", errSave)
|
||||
}
|
||||
staleRecords, errLoad := staleStore.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("staleStore.Load() error = %v", errLoad)
|
||||
}
|
||||
if len(staleRecords) != 2 {
|
||||
t.Fatalf("merged Load() returned %d records, want 2", len(staleRecords))
|
||||
}
|
||||
|
||||
newerRecordA := recordA
|
||||
newerRecordA.UpdatedAt = updatedAt.Add(time.Hour)
|
||||
if errSave := storeA.Save(context.Background(), []cliproxyauth.CooldownStateRecord{newerRecordA}); errSave != nil {
|
||||
t.Fatalf("storeA.Save(newer) error = %v", errSave)
|
||||
}
|
||||
if errSave := staleStore.Save(context.Background(), []cliproxyauth.CooldownStateRecord{recordB}); errSave != nil {
|
||||
t.Fatalf("staleStore.Save(without newer record) error = %v", errSave)
|
||||
}
|
||||
resurrectStore := &postgresCooldownStateStore{store: postgresStore}
|
||||
activeRecords, errLoad := resurrectStore.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("resurrectStore.Load() error = %v", errLoad)
|
||||
}
|
||||
if len(activeRecords) != 2 {
|
||||
t.Fatalf("Load() after stale delete returned %d records, want 2", len(activeRecords))
|
||||
}
|
||||
|
||||
if errSave := storeA.Save(context.Background(), nil); errSave != nil {
|
||||
t.Fatalf("storeA.Save(nil) error = %v", errSave)
|
||||
}
|
||||
if errSave := resurrectStore.Save(context.Background(), activeRecords); errSave != nil {
|
||||
t.Fatalf("resurrectStore.Save() error = %v", errSave)
|
||||
}
|
||||
reader := &postgresCooldownStateStore{store: postgresStore}
|
||||
loaded, errLoad := reader.Load(context.Background())
|
||||
if errLoad != nil {
|
||||
t.Fatalf("reader.Load() error = %v", errLoad)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].AuthID != recordB.AuthID {
|
||||
t.Fatalf("Load() after stale save = %#v, want only account-b", loaded)
|
||||
}
|
||||
}
|
||||
712
backend/internal/store/postgresstore.go
Normal file
712
backend/internal/store/postgresstore.go
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigTable = "config_store"
|
||||
defaultAuthTable = "auth_store"
|
||||
defaultCooldownTable = "cooldown_store"
|
||||
defaultConfigKey = "config"
|
||||
)
|
||||
|
||||
// PostgresStoreConfig captures configuration required to initialize a Postgres-backed store.
|
||||
type PostgresStoreConfig struct {
|
||||
DSN string
|
||||
Schema string
|
||||
ConfigTable string
|
||||
AuthTable string
|
||||
CooldownTable string
|
||||
SpoolDir string
|
||||
}
|
||||
|
||||
// PostgresStore persists configuration and authentication metadata using PostgreSQL as backend
|
||||
// while mirroring data to a local workspace so existing file-based workflows continue to operate.
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
cfg PostgresStoreConfig
|
||||
spoolRoot string
|
||||
configPath string
|
||||
authDir string
|
||||
cooldownStore *postgresCooldownStateStore
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPostgresStore establishes a connection to PostgreSQL and prepares the local workspace.
|
||||
func NewPostgresStore(ctx context.Context, cfg PostgresStoreConfig) (*PostgresStore, error) {
|
||||
trimmedDSN := strings.TrimSpace(cfg.DSN)
|
||||
if trimmedDSN == "" {
|
||||
return nil, fmt.Errorf("postgres store: DSN is required")
|
||||
}
|
||||
cfg.DSN = trimmedDSN
|
||||
if cfg.ConfigTable == "" {
|
||||
cfg.ConfigTable = defaultConfigTable
|
||||
}
|
||||
if cfg.AuthTable == "" {
|
||||
cfg.AuthTable = defaultAuthTable
|
||||
}
|
||||
if cfg.CooldownTable == "" {
|
||||
cfg.CooldownTable = defaultCooldownTable
|
||||
}
|
||||
|
||||
spoolRoot := strings.TrimSpace(cfg.SpoolDir)
|
||||
if spoolRoot == "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
spoolRoot = filepath.Join(cwd, "pgstore")
|
||||
} else {
|
||||
spoolRoot = filepath.Join(os.TempDir(), "pgstore")
|
||||
}
|
||||
}
|
||||
absSpool, err := filepath.Abs(spoolRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: resolve spool directory: %w", err)
|
||||
}
|
||||
configDir := filepath.Join(absSpool, "config")
|
||||
authDir := filepath.Join(absSpool, "auths")
|
||||
if err = os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: create config directory: %w", err)
|
||||
}
|
||||
if err = os.MkdirAll(authDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: open database connection: %w", err)
|
||||
}
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("postgres store: ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &PostgresStore{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
spoolRoot: absSpool,
|
||||
configPath: filepath.Join(configDir, "config.yaml"),
|
||||
authDir: authDir,
|
||||
}
|
||||
store.cooldownStore = &postgresCooldownStateStore{store: store}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database connection.
|
||||
func (s *PostgresStore) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// EnsureSchema creates the required tables (and schema when provided).
|
||||
func (s *PostgresStore) EnsureSchema(ctx context.Context) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("postgres store: not initialized")
|
||||
}
|
||||
if schema := strings.TrimSpace(s.cfg.Schema); schema != "" {
|
||||
query := fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdentifier(schema))
|
||||
if _, err := s.db.ExecContext(ctx, query); err != nil {
|
||||
return fmt.Errorf("postgres store: create schema: %w", err)
|
||||
}
|
||||
}
|
||||
configTable := s.fullTableName(s.cfg.ConfigTable)
|
||||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`, configTable)); err != nil {
|
||||
return fmt.Errorf("postgres store: create config table: %w", err)
|
||||
}
|
||||
authTable := s.fullTableName(s.cfg.AuthTable)
|
||||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id TEXT PRIMARY KEY,
|
||||
content JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`, authTable)); err != nil {
|
||||
return fmt.Errorf("postgres store: create auth table: %w", err)
|
||||
}
|
||||
cooldownTable := s.fullTableName(s.cfg.CooldownTable)
|
||||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
auth_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
content JSONB NOT NULL,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (auth_id, model)
|
||||
)
|
||||
`, cooldownTable)); err != nil {
|
||||
return fmt.Errorf("postgres store: create cooldown table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bootstrap synchronizes configuration and auth records between PostgreSQL and the local workspace.
|
||||
func (s *PostgresStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
|
||||
if err := s.EnsureSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncConfigFromDatabase(ctx, exampleConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncAuthFromDatabase(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigPath returns the managed configuration file path inside the spool directory.
|
||||
func (s *PostgresStore) ConfigPath() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.configPath
|
||||
}
|
||||
|
||||
// AuthDir returns the local directory containing mirrored auth files.
|
||||
func (s *PostgresStore) AuthDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.authDir
|
||||
}
|
||||
|
||||
// WorkDir exposes the root spool directory used for mirroring.
|
||||
func (s *PostgresStore) WorkDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.spoolRoot
|
||||
}
|
||||
|
||||
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
|
||||
// the Postgres-backed store controls its own workspace.
|
||||
func (s *PostgresStore) SetBaseDir(string) {}
|
||||
|
||||
// Save persists authentication metadata to disk and PostgreSQL.
|
||||
func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("postgres store: auth is nil")
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {
|
||||
return "", fmt.Errorf("postgres store: %w", errWeight)
|
||||
}
|
||||
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("postgres store: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("postgres store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case auth.Storage != nil:
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["disabled"] = auth.Disabled
|
||||
if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
|
||||
setter.SetMetadata(auth.Metadata)
|
||||
}
|
||||
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("postgres store: marshal metadata: %w", errMarshal)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
return path, nil
|
||||
}
|
||||
} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("postgres store: read existing metadata: %w", errRead)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
||||
return "", fmt.Errorf("postgres store: write temp auth file: %w", errWrite)
|
||||
}
|
||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
||||
return "", fmt.Errorf("postgres store: rename auth file: %w", errRename)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("postgres store: nothing to persist for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes[cliproxyauth.AttributePath] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourcePostgres
|
||||
|
||||
if strings.TrimSpace(auth.FileName) == "" {
|
||||
auth.FileName = auth.ID
|
||||
}
|
||||
|
||||
relID, err := s.relativeAuthID(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = s.upsertAuthRecord(ctx, relID, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates all auth records stored in PostgreSQL.
|
||||
func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
|
||||
query := fmt.Sprintf("SELECT id, content, created_at, updated_at FROM %s ORDER BY id", s.fullTableName(s.cfg.AuthTable))
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: list auth: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
auths := make([]*cliproxyauth.Auth, 0, 32)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
payload string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err = rows.Scan(&id, &payload, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: scan auth row: %w", err)
|
||||
}
|
||||
path, errPath := s.absoluteAuthPath(id)
|
||||
if errPath != nil {
|
||||
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
|
||||
continue
|
||||
}
|
||||
metadata := make(map[string]any)
|
||||
if err = json.Unmarshal([]byte(payload), &metadata); err != nil {
|
||||
log.WithError(err).Warnf("postgres store: skipping auth %s with invalid json", id)
|
||||
continue
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil {
|
||||
log.WithError(errWeight).Warnf("postgres store: skipping auth %s with invalid weight", id)
|
||||
continue
|
||||
}
|
||||
provider := strings.TrimSpace(valueAsString(metadata["type"]))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
attr := map[string]string{
|
||||
cliproxyauth.AttributePath: path,
|
||||
cliproxyauth.AttributeSourceBackend: cliproxyauth.AuthSourcePostgres,
|
||||
}
|
||||
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
|
||||
attr["email"] = email
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: normalizeAuthID(id),
|
||||
Provider: provider,
|
||||
FileName: normalizeAuthID(id),
|
||||
Label: labelFor(metadata),
|
||||
Status: cliproxyauth.StatusActive,
|
||||
Attributes: attr,
|
||||
Metadata: metadata,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
LastRefreshedAt: time.Time{},
|
||||
NextRefreshAfter: time.Time{},
|
||||
}
|
||||
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
|
||||
if disabled, ok := metadata["disabled"].(bool); ok && disabled {
|
||||
auth.Disabled = true
|
||||
auth.Status = cliproxyauth.StatusDisabled
|
||||
}
|
||||
auths = append(auths, auth)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: iterate auth rows: %w", err)
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// Delete removes an auth file and the corresponding database record.
|
||||
func (s *PostgresStore) Delete(ctx context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("postgres store: id is empty")
|
||||
}
|
||||
path, err := s.resolveDeletePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("postgres store: delete auth file: %w", err)
|
||||
}
|
||||
relID, err := s.relativeAuthID(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
|
||||
// PersistAuthFiles stores the provided auth file changes in PostgreSQL.
|
||||
func (s *PostgresStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, p := range paths {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
relID, err := s.relativeAuthID(trimmed)
|
||||
if err != nil {
|
||||
// Attempt to resolve absolute path under authDir.
|
||||
abs := trimmed
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(s.authDir, trimmed)
|
||||
}
|
||||
relID, err = s.relativeAuthID(abs)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("postgres store: ignoring auth path %s", trimmed)
|
||||
continue
|
||||
}
|
||||
trimmed = abs
|
||||
}
|
||||
if err = s.syncAuthFile(ctx, relID, trimmed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistConfig mirrors the local configuration file to PostgreSQL.
|
||||
func (s *PostgresStore) PersistConfig(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteConfigRecord(ctx)
|
||||
}
|
||||
return fmt.Errorf("postgres store: read config file: %w", err)
|
||||
}
|
||||
return s.persistConfig(ctx, data)
|
||||
}
|
||||
|
||||
// syncConfigFromDatabase writes the database-stored config to disk or seeds the database from template.
|
||||
func (s *PostgresStore) syncConfigFromDatabase(ctx context.Context, exampleConfigPath string) error {
|
||||
query := fmt.Sprintf("SELECT content FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
|
||||
var content string
|
||||
err := s.db.QueryRowContext(ctx, query, defaultConfigKey).Scan(&content)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, errStat := os.Stat(s.configPath); errors.Is(errStat, fs.ErrNotExist) {
|
||||
if exampleConfigPath != "" {
|
||||
if errCopy := misc.CopyConfigTemplate(exampleConfigPath, s.configPath); errCopy != nil {
|
||||
return fmt.Errorf("postgres store: copy example config: %w", errCopy)
|
||||
}
|
||||
} else {
|
||||
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
|
||||
return fmt.Errorf("postgres store: prepare config directory: %w", errCreate)
|
||||
}
|
||||
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
|
||||
return fmt.Errorf("postgres store: create empty config: %w", errWrite)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, errRead := os.ReadFile(s.configPath)
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("postgres store: read local config: %w", errRead)
|
||||
}
|
||||
if errPersist := s.persistConfig(ctx, data); errPersist != nil {
|
||||
return errPersist
|
||||
}
|
||||
case err != nil:
|
||||
return fmt.Errorf("postgres store: load config from database: %w", err)
|
||||
default:
|
||||
if err = os.MkdirAll(filepath.Dir(s.configPath), 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: prepare config directory: %w", err)
|
||||
}
|
||||
normalized := normalizeLineEndings(content)
|
||||
if err = os.WriteFile(s.configPath, []byte(normalized), 0o600); err != nil {
|
||||
return fmt.Errorf("postgres store: write config to spool: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAuthFromDatabase populates the local auth directory from PostgreSQL data.
|
||||
func (s *PostgresStore) syncAuthFromDatabase(ctx context.Context) error {
|
||||
query := fmt.Sprintf("SELECT id, content FROM %s", s.fullTableName(s.cfg.AuthTable))
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("postgres store: load auth from database: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if err = os.RemoveAll(s.authDir); err != nil {
|
||||
return fmt.Errorf("postgres store: reset auth directory: %w", err)
|
||||
}
|
||||
if err = os.MkdirAll(s.authDir, 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: recreate auth directory: %w", err)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
payload string
|
||||
)
|
||||
if err = rows.Scan(&id, &payload); err != nil {
|
||||
return fmt.Errorf("postgres store: scan auth row: %w", err)
|
||||
}
|
||||
path, errPath := s.absoluteAuthPath(id)
|
||||
if errPath != nil {
|
||||
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
|
||||
continue
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: create auth subdir: %w", err)
|
||||
}
|
||||
if err = os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
return fmt.Errorf("postgres store: write auth file: %w", err)
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return fmt.Errorf("postgres store: iterate auth rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) syncAuthFile(ctx context.Context, relID, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return fmt.Errorf("postgres store: read auth file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return s.persistAuth(ctx, relID, data)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) upsertAuthRecord(ctx context.Context, relID, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("postgres store: read auth file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return s.persistAuth(ctx, relID, data)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) persistAuth(ctx context.Context, relID string, data []byte) error {
|
||||
jsonPayload := json.RawMessage(data)
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (id, content, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||
`, s.fullTableName(s.cfg.AuthTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, relID, jsonPayload); err != nil {
|
||||
return fmt.Errorf("postgres store: upsert auth record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) deleteAuthRecord(ctx context.Context, relID string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.AuthTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, relID); err != nil {
|
||||
return fmt.Errorf("postgres store: delete auth record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) persistConfig(ctx context.Context, data []byte) error {
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (id, content, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||
`, s.fullTableName(s.cfg.ConfigTable))
|
||||
normalized := normalizeLineEndings(string(data))
|
||||
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey, normalized); err != nil {
|
||||
return fmt.Errorf("postgres store: upsert config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) deleteConfigRecord(ctx context.Context) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey); err != nil {
|
||||
return fmt.Errorf("postgres store: delete config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("postgres store: 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
|
||||
}
|
||||
return filepath.Join(s.authDir, fileName), nil
|
||||
}
|
||||
if auth.ID == "" {
|
||||
return "", fmt.Errorf("postgres store: missing id")
|
||||
}
|
||||
if filepath.IsAbs(auth.ID) {
|
||||
return auth.ID, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, filepath.FromSlash(auth.ID)), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) resolveDeletePath(id string) (string, error) {
|
||||
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
|
||||
return id, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, filepath.FromSlash(id)), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) relativeAuthID(path string) (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("postgres store: store not initialized")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(s.authDir, path)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.authDir, clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("postgres store: compute relative path: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("postgres store: path %s outside managed directory", path)
|
||||
}
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) absoluteAuthPath(id string) (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("postgres store: store not initialized")
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(id))
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("postgres store: invalid auth identifier %s", id)
|
||||
}
|
||||
path := filepath.Join(s.authDir, clean)
|
||||
rel, err := filepath.Rel(s.authDir, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("postgres store: resolved auth path escapes auth directory")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) fullTableName(name string) string {
|
||||
if strings.TrimSpace(s.cfg.Schema) == "" {
|
||||
return quoteIdentifier(name)
|
||||
}
|
||||
return quoteIdentifier(s.cfg.Schema) + "." + quoteIdentifier(name)
|
||||
}
|
||||
|
||||
func quoteIdentifier(identifier string) string {
|
||||
replaced := strings.ReplaceAll(identifier, "\"", "\"\"")
|
||||
return "\"" + replaced + "\""
|
||||
}
|
||||
|
||||
func valueAsString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case fmt.Stringer:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func labelFor(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["label"])); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["email"])); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["project_id"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAuthID(id string) string {
|
||||
return filepath.ToSlash(filepath.Clean(id))
|
||||
}
|
||||
|
||||
func normalizeLineEndings(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
return s
|
||||
}
|
||||
Loading…
Reference in a new issue