| 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/v6/internal/misc" |
| cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| log "github.com/sirupsen/logrus" |
| ) |
|
|
| const ( |
| objectStoreConfigKey = "config/config.yaml" |
| objectStoreAuthPrefix = "auths" |
| ) |
|
|
| |
| type ObjectStoreConfig struct { |
| Endpoint string |
| Bucket string |
| AccessKey string |
| SecretKey string |
| Region string |
| Prefix string |
| LocalRoot string |
| UseSSL bool |
| PathStyle bool |
| } |
|
|
| |
| |
| type ObjectTokenStore struct { |
| client *minio.Client |
| cfg ObjectStoreConfig |
| spoolRoot string |
| configPath string |
| authDir string |
| mu sync.Mutex |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| func (s *ObjectTokenStore) SetBaseDir(string) {} |
|
|
| |
| func (s *ObjectTokenStore) ConfigPath() string { |
| if s == nil { |
| return "" |
| } |
| return s.configPath |
| } |
|
|
| |
| func (s *ObjectTokenStore) AuthDir() string { |
| if s == nil { |
| return "" |
| } |
| return s.authDir |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) { |
| if auth == nil { |
| return "", fmt.Errorf("object store: auth is nil") |
| } |
|
|
| 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 err = auth.Storage.SaveTokenToFile(path); err != nil { |
| return "", err |
| } |
| case auth.Metadata != nil: |
| raw, errMarshal := json.Marshal(auth.Metadata) |
| if errMarshal != nil { |
| return "", fmt.Errorf("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["path"] = path |
|
|
| if strings.TrimSpace(auth.FileName) == "" { |
| auth.FileName = auth.ID |
| } |
|
|
| if err = s.uploadAuth(ctx, path); err != nil { |
| return "", err |
| } |
| return path, nil |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 { |
| if err := os.RemoveAll(s.authDir); err != nil { |
| return fmt.Errorf("object store: reset auth directory: %w", err) |
| } |
| if err := os.MkdirAll(s.authDir, 0o700); err != nil { |
| return fmt.Errorf("object store: recreate 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") |
| } |
| |
| if filepath.IsAbs(id) { |
| return id, nil |
| } |
| |
| |
| 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) |
| } |
| |
| 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) |
| } |
| 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{"path": path} |
| 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{}, |
| } |
| 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 |
| } |
|
|