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 }