360 lines
11 KiB
Go
360 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tinode/chat/server/logs"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
type adminInternalAPIConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
BaseURL string `json:"base_url"`
|
|
ServiceName string `json:"service_name"`
|
|
Secret string `json:"secret"`
|
|
TimeoutMS int `json:"timeout_ms"`
|
|
}
|
|
|
|
type adminInternalClient struct {
|
|
config adminInternalAPIConfig
|
|
http *http.Client
|
|
}
|
|
|
|
type tenantLoginIPWhitelistConfig struct {
|
|
TenantID types.TenantID `json:"tenant_id"`
|
|
AppList []string `json:"app_list"`
|
|
WebList []string `json:"web_list"`
|
|
}
|
|
|
|
type tenantLoginIPWhitelistProvider interface {
|
|
TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error)
|
|
}
|
|
|
|
var loginIPWhitelistProvider tenantLoginIPWhitelistProvider
|
|
|
|
type adminInternalTenantDTO struct {
|
|
ID types.TenantID `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
TenantDesc *string `json:"tenant_desc,omitempty"`
|
|
DBName string `json:"db_name"`
|
|
DBDSN string `json:"db_dsn"`
|
|
State types.TenantState `json:"state"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
CreatedBy int64 `json:"created_by"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
UpdatedBy int64 `json:"updated_by"`
|
|
}
|
|
|
|
func initAdminInternalAPI(raw json.RawMessage) error {
|
|
store.SetAdminInternalConfigProvider(nil)
|
|
loginIPWhitelistProvider = nil
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil
|
|
}
|
|
var cfg adminInternalAPIConfig
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return err
|
|
}
|
|
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
|
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
|
if cfg.ServiceName == "" {
|
|
cfg.ServiceName = "im-server"
|
|
}
|
|
if cfg.TimeoutMS <= 0 {
|
|
cfg.TimeoutMS = 3000
|
|
}
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" {
|
|
return errors.New("admin_internal_api enabled but base_url or secret is empty")
|
|
}
|
|
client := &adminInternalClient{
|
|
config: cfg,
|
|
http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond},
|
|
}
|
|
store.SetAdminInternalConfigProvider(client)
|
|
loginIPWhitelistProvider = client
|
|
logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
|
|
return nil
|
|
}
|
|
|
|
func (c *adminInternalClient) GetTenantByCode(code string) (*types.Tenant, error) {
|
|
var tenant adminInternalTenantDTO
|
|
if err := c.getJSON("/internal/im-config/tenants/by-code/"+url.PathEscape(strings.TrimSpace(code)), nil, &tenant); err != nil {
|
|
return nil, err
|
|
}
|
|
return tenant.toStoreTenant(), nil
|
|
}
|
|
|
|
func (c *adminInternalClient) GetTenantByID(id types.TenantID) (*types.Tenant, error) {
|
|
if !id.IsValid() {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
var tenant adminInternalTenantDTO
|
|
if err := c.getJSON("/internal/im-config/tenants/by-id/"+strconv.FormatInt(int64(id), 10), nil, &tenant); err != nil {
|
|
return nil, err
|
|
}
|
|
return tenant.toStoreTenant(), nil
|
|
}
|
|
|
|
func (c *adminInternalClient) ListActiveTenants(limit int) ([]types.Tenant, error) {
|
|
query := url.Values{}
|
|
query.Set("state", "active")
|
|
if limit > 0 {
|
|
query.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
var wrapper struct {
|
|
Items []adminInternalTenantDTO `json:"items"`
|
|
}
|
|
if err := c.getJSON("/internal/im-config/tenants", query, &wrapper); err != nil {
|
|
return nil, err
|
|
}
|
|
tenants := make([]types.Tenant, 0, len(wrapper.Items))
|
|
for _, item := range wrapper.Items {
|
|
if tenant := item.toStoreTenant(); tenant != nil {
|
|
tenants = append(tenants, *tenant)
|
|
}
|
|
}
|
|
return tenants, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) RegistrationSMSEnabled() (bool, error) {
|
|
var wrapper struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := c.getJSON("/internal/im-config/registration/sms", nil, &wrapper); err != nil {
|
|
return true, err
|
|
}
|
|
return wrapper.Enabled, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) {
|
|
query := url.Values{}
|
|
normalized := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
if key = strings.TrimSpace(key); key != "" {
|
|
normalized = append(normalized, key)
|
|
}
|
|
}
|
|
if len(normalized) > 0 {
|
|
query.Set("keys", strings.Join(normalized, ","))
|
|
}
|
|
var wrapper struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := c.getJSON("/internal/im-config/third-party/"+url.PathEscape(strings.TrimSpace(group)), query, &wrapper); err != nil {
|
|
return nil, err
|
|
}
|
|
if wrapper.Values == nil {
|
|
wrapper.Values = map[string]string{}
|
|
}
|
|
return wrapper.Values, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) ModerationProvider() (*types.ModerationProviderConfig, error) {
|
|
var result types.ModerationProviderConfig
|
|
if err := c.getJSON("/internal/im-config/moderation/provider", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error) {
|
|
var result types.ModerationPolicy
|
|
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/moderation-policy"
|
|
if err := c.getJSON(path, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error) {
|
|
var result types.SensitiveWordSet
|
|
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/sensitive-words"
|
|
if err := c.getJSON(path, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Items == nil {
|
|
result.Items = []*types.SensitiveWordRule{}
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
|
|
if !id.IsValid() {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
var result tenantLoginIPWhitelistConfig
|
|
path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/login-ip-whitelist"
|
|
if err := c.getJSON(path, nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
result.TenantID = id
|
|
if result.AppList == nil {
|
|
result.AppList = []string{}
|
|
}
|
|
if result.WebList == nil {
|
|
result.WebList = []string{}
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) LatestAppRelease(platform, channel string) (*types.AppRelease, error) {
|
|
query := url.Values{}
|
|
query.Set("platform", strings.ToLower(strings.TrimSpace(platform)))
|
|
query.Set("channel", strings.ToLower(strings.TrimSpace(channel)))
|
|
var dto struct {
|
|
ID int64 `json:"id"`
|
|
Platform string `json:"platform"`
|
|
Channel string `json:"channel"`
|
|
LatestVersion string `json:"latest_version"`
|
|
MinSupportedVersion string `json:"min_supported_version"`
|
|
UpdateType string `json:"update_type"`
|
|
ReleaseNotes string `json:"release_notes"`
|
|
StoreURL string `json:"store_url"`
|
|
PackageURL string `json:"package_url"`
|
|
Checksum string `json:"checksum"`
|
|
RemindAfterSeconds int `json:"remind_after_seconds"`
|
|
PublishedAt string `json:"published_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := c.getJSON("/internal/im-config/app/releases/latest", query, &dto); err != nil {
|
|
return nil, err
|
|
}
|
|
parseTime := func(raw string) time.Time {
|
|
parsed, _ := time.Parse(time.RFC3339Nano, raw)
|
|
return parsed
|
|
}
|
|
return &types.AppRelease{
|
|
ID: dto.ID, Platform: dto.Platform, Channel: dto.Channel,
|
|
LatestVersion: dto.LatestVersion, MinSupportedVersion: dto.MinSupportedVersion,
|
|
UpdateType: dto.UpdateType, ReleaseNotes: dto.ReleaseNotes, StoreURL: dto.StoreURL,
|
|
PackageURL: dto.PackageURL, Checksum: dto.Checksum, RemindAfterSeconds: dto.RemindAfterSeconds,
|
|
PublishedAt: parseTime(dto.PublishedAt), CreatedAt: parseTime(dto.CreatedAt), UpdatedAt: parseTime(dto.UpdatedAt),
|
|
}, nil
|
|
}
|
|
|
|
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
|
|
if c == nil || c.http == nil {
|
|
return types.ErrUnsupported
|
|
}
|
|
path = "/" + strings.TrimLeft(path, "/")
|
|
u, err := url.Parse(c.config.BaseURL + path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(query) > 0 {
|
|
u.RawQuery = query.Encode()
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = c.signRequest(req, nil); err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return fmt.Errorf("admin internal api status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
var parsed struct {
|
|
Code int `json:"code"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err = json.Unmarshal(body, &parsed); err != nil {
|
|
return err
|
|
}
|
|
if parsed.Code != 0 {
|
|
return fmt.Errorf("admin internal api code=%d", parsed.Code)
|
|
}
|
|
if out == nil || len(parsed.Data) == 0 || string(parsed.Data) == "null" {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(parsed.Data, out)
|
|
}
|
|
|
|
func (c *adminInternalClient) signRequest(req *http.Request, body []byte) error {
|
|
nonce, err := randomInternalNonce()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
|
bodyHash := sha256.Sum256(body)
|
|
signingText := strings.Join([]string{
|
|
req.Method,
|
|
req.URL.RequestURI(),
|
|
hex.EncodeToString(bodyHash[:]),
|
|
timestamp,
|
|
nonce,
|
|
}, "\n")
|
|
req.Header.Set(internalHeaderService, c.config.ServiceName)
|
|
req.Header.Set(internalHeaderTimestamp, timestamp)
|
|
req.Header.Set(internalHeaderNonce, nonce)
|
|
req.Header.Set(internalHeaderSignature, signInternalRequest(c.config.Secret, signingText))
|
|
req.Header.Set("Accept", "application/json")
|
|
if len(body) > 0 {
|
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
|
req.ContentLength = int64(len(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomInternalNonce() (string, error) {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
func signInternalRequest(secret, signingText string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte(signingText))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (t adminInternalTenantDTO) toStoreTenant() *types.Tenant {
|
|
if t.ID.IsZero() && strings.TrimSpace(t.Code) == "" {
|
|
return nil
|
|
}
|
|
return &types.Tenant{
|
|
ID: t.ID,
|
|
Code: strings.TrimSpace(t.Code),
|
|
Name: t.Name,
|
|
TenantDesc: t.TenantDesc,
|
|
DBName: t.DBName,
|
|
DBDSN: t.DBDSN,
|
|
State: t.State,
|
|
CreatedAt: t.CreatedAt,
|
|
CreatedBy: t.CreatedBy,
|
|
UpdatedAt: t.UpdatedAt,
|
|
UpdatedBy: t.UpdatedBy,
|
|
}
|
|
}
|