Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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
}

View 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
}

View 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)
}
}

View 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
}