Files
im_server/server/im_agora_http.go
T

279 lines
8.8 KiB
Go

package main
import (
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"time"
rtctokenbuilder "github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src/rtctokenbuilder2"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
imReasonAgoraDisabled = "AGORA_DISABLED"
imReasonAgoraConfigMissing = "AGORA_CONFIG_MISSING"
imReasonCallNotFound = "CALL_NOT_FOUND"
imReasonCallNotAllowed = "CALL_NOT_ALLOWED"
imReasonCallStateInvalid = "CALL_STATE_INVALID"
)
type imCallRTCJoinRequest struct {
Topic string `json:"topic"`
CallID string `json:"call_id"`
Seq int64 `json:"seq"`
CallType string `json:"call_type"`
Role string `json:"role"`
}
type imAgoraConfig struct {
Enabled bool
AuthMode string
AppID string
AppCertificate string
TokenTTLSeconds int
ChannelPrefix string
UIDMode string
}
func serveIMCallRTCJoin(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imCallRTCJoinRequest
if err := json.NewDecoder(http.MaxBytesReader(wrt, req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
body.Topic = strings.TrimSpace(body.Topic)
body.CallID = strings.TrimSpace(body.CallID)
body.CallType = strings.ToLower(strings.TrimSpace(body.CallType))
body.Role = strings.ToLower(strings.TrimSpace(body.Role))
if body.Topic == "" || body.CallID == "" || body.Seq <= 0 ||
(body.CallType != constCallTypeAudio && body.CallType != constCallTypeVideo) ||
(body.Role != "caller" && body.Role != "callee") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
normalizedTopic, _ := normalizeP2PConversationTopic(body.Topic, principal.Uid)
if normalizedTopic == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
body.Topic = normalizedTopic
config, err := loadIMAgoraConfig()
if err != nil {
if errors.Is(err, types.ErrNotFound) || errors.Is(err, types.ErrMalformed) {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !config.Enabled {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30061, imReasonAgoraDisabled, "agora disabled", nil)
return
}
sub, err := store.Subs.Get(principal.TenantID, body.Topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
return
}
record, err := findCallRecordForRTCJoin(principal.TenantID, principal.Uid, body)
if err != nil {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30063, imReasonCallNotFound, "call not found", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30062, imReasonCallNotAllowed, "call not allowed", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
return
}
if !callStateAllowsRTCJoin(record.Status) {
writeIMError(wrt, req, http.StatusConflict, 30064, imReasonCallStateInvalid, "call state invalid", map[string]any{"status": record.Status})
return
}
channelName := config.ChannelPrefix + record.CallID
uid, err := agoraUIDForUser(config, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora config missing", nil)
return
}
token := ""
var expiresAt any
if config.AuthMode == "token" {
token, expiresAt, err = buildAgoraRTCToken(config, channelName, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusServiceUnavailable, 30060, imReasonAgoraConfigMissing, "agora rtc token generation failed", nil)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"provider": "agora",
"auth_mode": config.AuthMode,
"app_id": config.AppID,
"channel_name": channelName,
"uid": uid,
"token": token,
"token_expires_at": expiresAt,
"call_id": record.CallID,
"topic": record.Topic,
"seq": record.SeqID,
"call_type": record.CallType,
"role": body.Role,
})
}
func loadIMAgoraConfig() (imAgoraConfig, error) {
values, err := store.ThirdPartyConfig.Get("im_shengwang",
"agora_enabled",
"agora_auth_mode",
"agora_app_id",
"agora_app_certificate",
"agora_token_ttl_seconds",
"agora_channel_prefix",
"agora_uid_mode",
)
if err != nil {
return imAgoraConfig{}, err
}
config := imAgoraConfig{
Enabled: imTruthy(values["agora_enabled"]),
AuthMode: strings.ToLower(strings.TrimSpace(values["agora_auth_mode"])),
AppID: strings.TrimSpace(values["agora_app_id"]),
AppCertificate: strings.TrimSpace(values["agora_app_certificate"]),
TokenTTLSeconds: 3600,
ChannelPrefix: strings.TrimSpace(values["agora_channel_prefix"]),
UIDMode: strings.ToLower(strings.TrimSpace(values["agora_uid_mode"])),
}
if config.AuthMode == "" {
config.AuthMode = "app_id"
}
if config.ChannelPrefix == "" {
config.ChannelPrefix = "call_"
}
if config.UIDMode == "" {
config.UIDMode = "string"
}
if ttl := strings.TrimSpace(values["agora_token_ttl_seconds"]); ttl != "" {
parsed, err := strconv.Atoi(ttl)
if err != nil || parsed <= 0 {
return imAgoraConfig{}, types.ErrMalformed
}
config.TokenTTLSeconds = parsed
}
if !config.Enabled {
return config, nil
}
if config.AppID == "" || (config.AuthMode != "app_id" && config.AuthMode != "token") {
return imAgoraConfig{}, types.ErrMalformed
}
if config.AuthMode == "token" && config.AppCertificate == "" {
return imAgoraConfig{}, types.ErrMalformed
}
return config, nil
}
func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) {
if store.Calls == nil {
return nil, types.ErrUnsupported
}
records, _, err := store.Calls.List(tenantID, uid, body.Topic, "", 100)
if err != nil {
return nil, err
}
for i := range records {
record := &records[i]
if record.CallID != body.CallID || record.Topic != body.Topic || record.SeqID != body.Seq || record.CallType != body.CallType {
continue
}
if record.CallerID != uid && record.CalleeID != uid {
return nil, types.ErrPermissionDenied
}
if body.Role == "caller" && record.CallerID != uid {
return nil, types.ErrPermissionDenied
}
if body.Role == "callee" && record.CalleeID != uid {
return nil, types.ErrPermissionDenied
}
return record, nil
}
return nil, types.ErrNotFound
}
func callStateAllowsRTCJoin(status string) bool {
switch status {
case types.CallStatusStarted, types.CallStatusAccepted:
return true
default:
return false
}
}
func agoraUIDForUser(config imAgoraConfig, uid types.Uid) (any, error) {
switch config.UIDMode {
case "int":
decoded := store.DecodeUid(uid)
if decoded <= 0 || decoded > math.MaxUint32 {
return nil, types.ErrMalformed
}
return decoded, nil
case "string":
return uid.UserId(), nil
default:
return nil, types.ErrMalformed
}
}
func buildAgoraRTCToken(config imAgoraConfig, channelName string, uid types.Uid) (string, string, error) {
ttl := uint32(config.TokenTTLSeconds)
if config.UIDMode == "int" {
decoded := store.DecodeUid(uid)
if decoded <= 0 || decoded > math.MaxUint32 {
return "", "", types.ErrMalformed
}
token, err := rtctokenbuilder.BuildTokenWithUid(config.AppID, config.AppCertificate, channelName, uint32(decoded), rtctokenbuilder.RolePublisher, ttl, ttl)
if err != nil {
return "", "", err
}
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
}
account := uid.UserId()
if account == "" {
return "", "", types.ErrMalformed
}
token, err := rtctokenbuilder.BuildTokenWithUserAccount(config.AppID, config.AppCertificate, channelName, account, rtctokenbuilder.RolePublisher, ttl, ttl)
if err != nil {
return "", "", err
}
return token, agoraTokenExpiresAt(config.TokenTTLSeconds), nil
}
func imTruthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func agoraTokenExpiresAt(ttlSeconds int) string {
return time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Format(time.RFC3339Nano)
}