505 lines
14 KiB
Go
505 lines
14 KiB
Go
package management
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
)
|
|
|
|
// Download single auth file by name
|
|
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
|
name := strings.TrimSpace(c.Query("name"))
|
|
if isUnsafeAuthFileName(name) {
|
|
c.JSON(400, gin.H{"error": "invalid name"})
|
|
return
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
|
c.JSON(400, gin.H{"error": "name must end with .json"})
|
|
return
|
|
}
|
|
full := filepath.Join(h.cfg.AuthDir, name)
|
|
data, err := os.ReadFile(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
c.JSON(404, gin.H{"error": "file not found"})
|
|
} else {
|
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
|
}
|
|
return
|
|
}
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
|
|
c.Data(200, "application/json", data)
|
|
}
|
|
|
|
// Upload auth file: multipart or raw JSON with ?name=
|
|
func (h *Handler) UploadAuthFile(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
|
|
fileHeaders, errMultipart := h.multipartAuthFileHeaders(c)
|
|
if errMultipart != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
|
|
return
|
|
}
|
|
if len(fileHeaders) == 1 {
|
|
if _, errUpload := h.storeUploadedAuthFile(ctx, fileHeaders[0]); errUpload != nil {
|
|
if errors.Is(errUpload, errAuthFileMustBeJSON) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
return
|
|
}
|
|
if len(fileHeaders) > 1 {
|
|
uploaded := make([]string, 0, len(fileHeaders))
|
|
failed := make([]gin.H, 0)
|
|
for _, file := range fileHeaders {
|
|
name, errUpload := h.storeUploadedAuthFile(ctx, file)
|
|
if errUpload != nil {
|
|
failureName := ""
|
|
if file != nil {
|
|
failureName = filepath.Base(file.Filename)
|
|
}
|
|
msg := errUpload.Error()
|
|
if errors.Is(errUpload, errAuthFileMustBeJSON) {
|
|
msg = "file must be .json"
|
|
}
|
|
failed = append(failed, gin.H{"name": failureName, "error": msg})
|
|
continue
|
|
}
|
|
uploaded = append(uploaded, name)
|
|
}
|
|
if len(failed) > 0 {
|
|
c.JSON(http.StatusMultiStatus, gin.H{
|
|
"status": "partial",
|
|
"uploaded": len(uploaded),
|
|
"files": uploaded,
|
|
"failed": failed,
|
|
})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "uploaded": len(uploaded), "files": uploaded})
|
|
return
|
|
}
|
|
if c.ContentType() == "multipart/form-data" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(c.Query("name"))
|
|
if isUnsafeAuthFileName(name) {
|
|
c.JSON(400, gin.H{"error": "invalid name"})
|
|
return
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
|
c.JSON(400, gin.H{"error": "name must end with .json"})
|
|
return
|
|
}
|
|
data, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
|
return
|
|
}
|
|
if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
}
|
|
|
|
// Delete auth files: single by name or all
|
|
func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
names, errNames := requestedAuthFileNamesForDelete(c)
|
|
if errNames != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
|
|
return
|
|
}
|
|
if len(names) == 0 {
|
|
c.JSON(400, gin.H{"error": "invalid name"})
|
|
return
|
|
}
|
|
if len(names) != 1 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "exactly one account is required"})
|
|
return
|
|
}
|
|
if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
|
|
c.JSON(status, gin.H{"error": errDelete.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func (h *Handler) multipartAuthFileHeaders(c *gin.Context) ([]*multipart.FileHeader, error) {
|
|
if h == nil || c == nil || c.ContentType() != "multipart/form-data" {
|
|
return nil, nil
|
|
}
|
|
form, err := c.MultipartForm()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if form == nil || len(form.File) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(form.File))
|
|
for key := range form.File {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
headers := make([]*multipart.FileHeader, 0)
|
|
for _, key := range keys {
|
|
headers = append(headers, form.File[key]...)
|
|
}
|
|
return headers, nil
|
|
}
|
|
|
|
func (h *Handler) storeUploadedAuthFile(ctx context.Context, file *multipart.FileHeader) (string, error) {
|
|
if file == nil {
|
|
return "", fmt.Errorf("no file uploaded")
|
|
}
|
|
name := filepath.Base(strings.TrimSpace(file.Filename))
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
|
return "", errAuthFileMustBeJSON
|
|
}
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open uploaded file: %w", err)
|
|
}
|
|
defer src.Close()
|
|
|
|
data, err := io.ReadAll(src)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read uploaded file: %w", err)
|
|
}
|
|
if err := h.writeAuthFile(ctx, name, data); err != nil {
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) error {
|
|
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
|
if !filepath.IsAbs(dst) {
|
|
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
|
dst = abs
|
|
}
|
|
}
|
|
auth, err := h.buildAuthFromFileData(dst, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
|
return fmt.Errorf("failed to write file: %w", errWrite)
|
|
}
|
|
if err := h.upsertAuthRecord(ctx, auth); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
names := uniqueAuthFileNames(c.QueryArray("name"))
|
|
if len(names) > 0 {
|
|
return names, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read body")
|
|
}
|
|
body = bytes.TrimSpace(body)
|
|
if len(body) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var objectBody struct {
|
|
Name string `json:"name"`
|
|
Names []string `json:"names"`
|
|
}
|
|
if body[0] == '[' {
|
|
var arrayBody []string
|
|
if err := json.Unmarshal(body, &arrayBody); err != nil {
|
|
return nil, fmt.Errorf("invalid request body")
|
|
}
|
|
return uniqueAuthFileNames(arrayBody), nil
|
|
}
|
|
if err := json.Unmarshal(body, &objectBody); err != nil {
|
|
return nil, fmt.Errorf("invalid request body")
|
|
}
|
|
|
|
out := make([]string, 0, len(objectBody.Names)+1)
|
|
if strings.TrimSpace(objectBody.Name) != "" {
|
|
out = append(out, objectBody.Name)
|
|
}
|
|
out = append(out, objectBody.Names...)
|
|
return uniqueAuthFileNames(out), nil
|
|
}
|
|
|
|
func uniqueAuthFileNames(names []string) []string {
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(names))
|
|
out := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) {
|
|
name = strings.TrimSpace(name)
|
|
if isUnsafeAuthFileName(name) {
|
|
return "", http.StatusBadRequest, fmt.Errorf("invalid name")
|
|
}
|
|
|
|
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
|
targetID := ""
|
|
targetAuth := h.findAuthForDelete(name)
|
|
if targetAuth == nil || !strings.EqualFold(strings.TrimSpace(targetAuth.Provider), "codex") || targetAuth.AuthKind() != "oauth" {
|
|
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
|
|
}
|
|
if targetAuth != nil {
|
|
if !isPluginVirtualSourceDelete(name, targetAuth) {
|
|
return filepath.Base(name), http.StatusConflict, errPluginVirtualAuth
|
|
}
|
|
targetID = strings.TrimSpace(targetAuth.ID)
|
|
if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" {
|
|
targetPath = path
|
|
}
|
|
}
|
|
if !filepath.IsAbs(targetPath) {
|
|
if abs, errAbs := filepath.Abs(targetPath); errAbs == nil {
|
|
targetPath = abs
|
|
}
|
|
}
|
|
if errRemove := os.Remove(targetPath); errRemove != nil {
|
|
if os.IsNotExist(errRemove) {
|
|
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
|
|
}
|
|
return filepath.Base(name), http.StatusInternalServerError, fmt.Errorf("failed to remove file: %w", errRemove)
|
|
}
|
|
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
|
|
return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
|
|
}
|
|
h.removeAuthsForPath(ctx, targetPath, targetID)
|
|
return filepath.Base(name), http.StatusOK, nil
|
|
}
|
|
|
|
func isPluginVirtualSourceDelete(name string, auth *coreauth.Auth) bool {
|
|
if !coreauth.IsPluginVirtualAuth(auth) {
|
|
return true
|
|
}
|
|
sourcePath := strings.TrimSpace(authAttribute(auth, coreauth.AttributeVirtualSource))
|
|
if sourcePath == "" {
|
|
sourcePath = strings.TrimSpace(authAttribute(auth, "path"))
|
|
}
|
|
if sourcePath == "" {
|
|
return false
|
|
}
|
|
return strings.EqualFold(filepath.Base(strings.TrimSpace(name)), filepath.Base(sourcePath))
|
|
}
|
|
|
|
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
|
|
if h == nil || h.authManager == nil {
|
|
return nil
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
return auth
|
|
}
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(auth.FileName) == name {
|
|
return auth
|
|
}
|
|
if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name {
|
|
return auth
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Handler) authIDForPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
path = filepath.Clean(path)
|
|
if !filepath.IsAbs(path) {
|
|
if abs, errAbs := filepath.Abs(path); errAbs == nil {
|
|
path = abs
|
|
}
|
|
}
|
|
id := path
|
|
if h != nil && h.cfg != nil {
|
|
authDir := strings.TrimSpace(h.cfg.AuthDir)
|
|
if resolvedAuthDir, errResolve := util.ResolveAuthDir(authDir); errResolve == nil && resolvedAuthDir != "" {
|
|
authDir = resolvedAuthDir
|
|
}
|
|
if authDir != "" {
|
|
authDir = filepath.Clean(authDir)
|
|
if !filepath.IsAbs(authDir) {
|
|
if abs, errAbs := filepath.Abs(authDir); errAbs == nil {
|
|
authDir = abs
|
|
}
|
|
}
|
|
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
|
|
id = rel
|
|
}
|
|
}
|
|
}
|
|
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
|
if runtime.GOOS == "windows" {
|
|
id = strings.ToLower(id)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
|
|
if h.authManager == nil {
|
|
return nil
|
|
}
|
|
auth, err := h.buildAuthFromFileData(path, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h.upsertAuthRecord(ctx, auth)
|
|
}
|
|
|
|
func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("auth path is empty")
|
|
}
|
|
if data == nil {
|
|
var err error
|
|
data, err = os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read auth file: %w", err)
|
|
}
|
|
}
|
|
metadata := make(map[string]any)
|
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
return nil, fmt.Errorf("invalid auth file: %w", err)
|
|
}
|
|
coreauth.NormalizeCredentialMetadata(metadata)
|
|
provider, _ := metadata["type"].(string)
|
|
if provider == "" {
|
|
provider = "unknown"
|
|
}
|
|
label := provider
|
|
if email, ok := metadata["email"].(string); ok && email != "" {
|
|
label = email
|
|
}
|
|
lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
|
|
|
|
authID := h.authIDForPath(path)
|
|
if authID == "" {
|
|
authID = path
|
|
}
|
|
auth := (*coreauth.Auth)(nil)
|
|
if h != nil && h.cfg != nil {
|
|
sctx := &synthesizer.SynthesisContext{
|
|
Config: h.cfg,
|
|
AuthDir: h.cfg.AuthDir,
|
|
Now: time.Now(),
|
|
IDGenerator: synthesizer.NewStableIDGenerator(),
|
|
}
|
|
generated, errSynthesize := synthesizer.SynthesizeAuthFile(sctx, path, data)
|
|
if errSynthesize != nil {
|
|
return nil, fmt.Errorf("invalid auth file: %w", errSynthesize)
|
|
}
|
|
if len(generated) > 0 && generated[0] != nil {
|
|
auth = generated[0].Clone()
|
|
}
|
|
}
|
|
if auth == nil {
|
|
auth = &coreauth.Auth{
|
|
ID: authID,
|
|
Provider: provider,
|
|
Label: label,
|
|
Status: coreauth.StatusActive,
|
|
Attributes: map[string]string{
|
|
"path": path,
|
|
"source": path,
|
|
},
|
|
Metadata: metadata,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
auth.ID = authID
|
|
auth.FileName = filepath.Base(path)
|
|
if hasLastRefresh {
|
|
auth.LastRefreshedAt = lastRefresh
|
|
}
|
|
if h != nil && h.authManager != nil {
|
|
if existing, ok := h.authManager.GetByID(authID); ok {
|
|
auth.CreatedAt = existing.CreatedAt
|
|
if !hasLastRefresh {
|
|
auth.LastRefreshedAt = existing.LastRefreshedAt
|
|
}
|
|
auth.NextRefreshAfter = existing.NextRefreshAfter
|
|
auth.Runtime = existing.Runtime
|
|
}
|
|
}
|
|
coreauth.ApplyCustomHeadersFromMetadata(auth)
|
|
return auth, nil
|
|
}
|
|
|
|
func (h *Handler) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error {
|
|
if h == nil || h.authManager == nil || auth == nil {
|
|
return nil
|
|
}
|
|
if existing, ok := h.authManager.GetByID(auth.ID); ok {
|
|
auth.CreatedAt = existing.CreatedAt
|
|
_, err := h.authManager.Update(ctx, auth)
|
|
return err
|
|
}
|
|
_, err := h.authManager.Register(ctx, auth)
|
|
return err
|
|
}
|