- 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.
378 lines
9.1 KiB
Go
378 lines
9.1 KiB
Go
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
|
||
}
|