yao/openapi/setting/cloud.go
Max 2993b0b946 feat(messenger): enhance dynamic SMTP resolution and identity handling
- Implemented dynamic SMTP provider resolution based on user/team context, improving email sending capabilities.
- Updated the Send and SendT methods to utilize identity information from the context for dynamic provider selection.
- Refactored cloud encryption and decryption methods to delegate to the setting package, streamlining cryptographic operations.
- Enhanced team invitation email sending to include identity context, ensuring accurate user/team information is used.
- Removed obsolete crypto helper functions, simplifying the codebase and improving maintainability.
2026-05-04 18:15:21 +08:00

378 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package setting
import (
_ "embed"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/setting"
"gopkg.in/yaml.v3"
)
//go:embed cloud_presets.yml
var cloudPresetsYML []byte
const (
cloudNS = "cloud"
cloudMaskChars = 4
cloudEncPrefix = "enc:"
)
// cloudPresets holds the parsed region list from the embedded YAML.
type cloudPresets struct {
Regions []CloudRegion `yaml:"regions"`
}
var cloudRegions []CloudRegion
func init() {
var p cloudPresets
if err := yaml.Unmarshal(cloudPresetsYML, &p); err == nil {
cloudRegions = p.Regions
}
}
func cloudDefaultRegion() CloudRegion {
for _, r := range cloudRegions {
if r.Default {
return r
}
}
if len(cloudRegions) > 0 {
return cloudRegions[0]
}
return CloudRegion{Key: "us", APIURL: "https://api-us.yao.run"}
}
func cloudFindRegion(key string) *CloudRegion {
for i := range cloudRegions {
if cloudRegions[i].Key == key {
return &cloudRegions[i]
}
}
return nil
}
func cloudScope(info *oauthTypes.AuthorizedInfo) setting.ScopeID {
if info.TeamID != "" {
return setting.ScopeID{Scope: setting.ScopeTeam, TeamID: info.TeamID}
}
return setting.ScopeID{Scope: setting.ScopeUser, UserID: info.UserID}
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// handleCloudGet returns the cloud configuration for the current team.
// GET /setting/cloud
func handleCloudGet(c *gin.Context) {
info := authorized.GetInfo(c)
def := cloudDefaultRegion()
var saved map[string]interface{}
if setting.Global != nil {
saved, _ = setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS)
}
data := CloudPageData{
Regions: cloudRegions,
Region: def.Key,
APIURL: def.APIURL,
APIKey: "",
Status: "unconfigured",
}
if saved != nil {
if v, ok := saved["region"].(string); ok && v != "" {
data.Region = v
}
if v, ok := saved["api_url"].(string); ok && v != "" {
data.APIURL = v
}
if v, ok := saved["api_key"].(string); ok && v != "" {
data.APIKey = cloudMaskKey(cloudDecrypt(v))
}
if v, ok := saved["status"].(string); ok && v != "" {
data.Status = v
}
}
response.RespondWithSuccess(c, http.StatusOK, data)
}
// handleCloudUpdate saves the cloud configuration.
// When api_key is provided, validates it by calling the cloud API before saving.
// PUT /setting/cloud
func handleCloudUpdate(c *gin.Context) {
if !guardOwner(c) {
return
}
info := authorized.GetInfo(c)
scope := cloudScope(info)
var body struct {
Region string `json:"region"`
APIURL string `json:"api_url"`
APIKey string `json:"api_key"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, http.StatusBadRequest, "invalid request body")
return
}
if body.Region != "" {
if r := cloudFindRegion(body.Region); r == nil {
respondError(c, http.StatusBadRequest, fmt.Sprintf("unknown region: %s", body.Region))
return
}
}
if setting.Global == nil {
respondError(c, http.StatusInternalServerError, "setting registry not initialized")
return
}
existing, _ := setting.Global.Get(scope, cloudNS)
m := make(map[string]interface{})
for k, v := range existing {
m[k] = v
}
if body.Region != "" {
m["region"] = body.Region
}
if body.APIURL != "" {
m["api_url"] = body.APIURL
}
// Resolve the effective api_url for key validation
apiURL := body.APIURL
if apiURL == "" {
if v, ok := m["api_url"].(string); ok {
apiURL = v
}
}
if apiURL == "" {
if body.Region != "" {
if r := cloudFindRegion(body.Region); r != nil {
apiURL = r.APIURL
}
}
if apiURL == "" {
apiURL = cloudDefaultRegion().APIURL
}
}
if body.APIKey != "" {
if err := cloudValidateKey(apiURL, body.APIKey); err != nil {
respondError(c, http.StatusBadRequest, fmt.Sprintf("API key validation failed: %s", err.Error()))
return
}
m["api_key"] = cloudEncrypt(body.APIKey)
m["status"] = "connected"
}
hasKey := false
if v, ok := m["api_key"].(string); ok && v != "" {
hasKey = true
}
if _, ok := m["status"].(string); !ok {
if hasKey {
m["status"] = "disconnected"
} else {
m["status"] = "unconfigured"
}
}
if _, err := setting.Global.Set(scope, cloudNS, m); err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
invalidateCloudModelCache()
def := cloudDefaultRegion()
result := CloudPageData{
Regions: cloudRegions,
Region: def.Key,
APIURL: def.APIURL,
APIKey: "",
Status: "unconfigured",
}
if v, ok := m["region"].(string); ok && v != "" {
result.Region = v
}
if v, ok := m["api_url"].(string); ok && v != "" {
result.APIURL = v
}
if v, ok := m["api_key"].(string); ok && v != "" {
result.APIKey = cloudMaskKey(cloudDecrypt(v))
}
if v, ok := m["status"].(string); ok && v != "" {
result.Status = v
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
// cloudValidateKey verifies the API key by calling GET {apiURL}/v1/models.
func cloudValidateKey(apiURL, apiKey string) error {
url := strings.TrimRight(apiURL, "/") + "/v1/models"
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("invalid API key (HTTP %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned HTTP %d", resp.StatusCode)
}
return nil
}
// handleCloudTest tests the cloud connection by calling GET {api_url}/v1/models.
// Caller must provide api_url and api_key in the request body.
// POST /setting/cloud/test
func handleCloudTest(c *gin.Context) {
if !guardOwner(c) {
return
}
var input struct {
APIURL string `json:"api_url"`
APIKey string `json:"api_key"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondError(c, http.StatusBadRequest, "invalid request body")
return
}
if input.APIURL == "" || input.APIKey == "" {
respondError(c, http.StatusBadRequest, "api_url and api_key are required")
return
}
url := strings.TrimRight(input.APIURL, "/") + "/v1/models"
start := time.Now()
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+input.APIKey)
resp, err := client.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil {
response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{
Success: false,
Message: fmt.Sprintf("Connection failed: %s", err.Error()),
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{
Success: false,
Message: fmt.Sprintf("Server returned HTTP %d", resp.StatusCode),
})
return
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
response.RespondWithSuccess(c, http.StatusOK, CloudTestResult{
Success: true,
Message: "Connection successful",
LatencyMs: latency,
})
}
// handleCloudRefresh invalidates the cloud model cache and re-fetches the model list.
// POST /setting/cloud/refresh
func handleCloudRefresh(c *gin.Context) {
if !guardOwner(c) {
return
}
info := authorized.GetInfo(c)
scope := cloudScope(info)
saved, _ := setting.Global.Get(scope, cloudNS)
if saved == nil {
respondError(c, http.StatusBadRequest, "cloud service not configured")
return
}
status, _ := saved["status"].(string)
if status != "connected" {
respondError(c, http.StatusBadRequest, "cloud service not connected")
return
}
encKey, _ := saved["api_key"].(string)
if encKey == "" {
respondError(c, http.StatusBadRequest, "no API key configured")
return
}
apiURL := resolveCloudAPIURL(saved)
invalidateCloudModelCache()
models := fetchCloudModels(apiURL, cloudDecrypt(encKey))
response.RespondWithSuccess(c, http.StatusOK, map[string]interface{}{
"success": true,
"count": len(models),
})
}
// ---------------------------------------------------------------------------
// Crypto helpers delegates to setting.Encrypt / setting.Decrypt
// ---------------------------------------------------------------------------
func cloudEncrypt(plaintext string) string {
return setting.Encrypt(plaintext)
}
func cloudDecrypt(value string) string {
return setting.Decrypt(value)
}
// DecryptValue decrypts a value encrypted by cloudEncrypt.
func DecryptValue(s string) string {
return setting.Decrypt(s)
}
func cloudMaskKey(key string) string {
if key == "" {
return ""
}
if len(key) <= cloudMaskChars {
return strings.Repeat("*", len(key))
}
prefix := key[:3]
suffix := key[len(key)-cloudMaskChars:]
return prefix + "..." + suffix
}