Files
gochat/internal/router/channel_callbacks.go
T

461 lines
18 KiB
Go

package router
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
func googleEmailCallback(db *gorm.DB) gin.HandlerFunc {
return emailOAuthCallback(db, emailCallbackConfig{
Provider: "google",
ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID",
SecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
TokenURLEnv: "GOOGLE_OAUTH_TOKEN_URL",
DefaultURL: "https://oauth2.googleapis.com/token",
IMAPAddress: "imap.gmail.com",
StateSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
})
}
func microsoftEmailCallback(db *gorm.DB) gin.HandlerFunc {
return emailOAuthCallback(db, emailCallbackConfig{
Provider: "microsoft",
ClientIDEnv: "AZURE_APP_ID",
SecretEnv: "AZURE_APP_SECRET",
TokenURLEnv: "MICROSOFT_OAUTH_TOKEN_URL",
DefaultURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
IMAPAddress: "outlook.office365.com",
StateSecretEnv: "AZURE_APP_SECRET",
})
}
type emailCallbackConfig struct {
Provider string
ClientIDEnv string
SecretEnv string
TokenURLEnv string
DefaultURL string
IMAPAddress string
StateSecretEnv string
}
func emailOAuthCallback(db *gorm.DB, cfg emailCallbackConfig) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv(cfg.StateSecretEnv))
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: envOrDefault(cfg.TokenURLEnv, cfg.DefaultURL),
ClientID: os.Getenv(cfg.ClientIDEnv),
ClientSecret: os.Getenv(cfg.SecretEnv),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/" + cfg.Provider + "/callback",
})
claims, claimErr := parseJWTClaimsUnverified(body["id_token"])
if err != nil || claimErr != nil || strings.TrimSpace(claimString(claims, "email")) == "" {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
email := claimString(claims, "email")
login := email
if cfg.Provider == "microsoft" {
if value := firstNonBlank(claimString(claims, "preferred_username"), claimString(claims, "upn")); value != "" {
login = value
}
}
name := firstNonBlank(claimString(claims, "name"), strings.Split(email, "@")[0])
inbox, existed, err := upsertEmailOAuthInbox(c, db, accountID, email, login, name, cfg.IMAPAddress, cfg.Provider, body)
if err != nil {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
if existed {
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
return
}
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
}
}
func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("INSTAGRAM_APP_SECRET"))
if !ok {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
if c.Query("error") != "" {
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{
"error_type": firstNonBlank(c.Query("error"), "authorization_error"),
"code": "400",
"error_message": firstNonBlank(c.Query("error_description"), "Authorization was denied"),
}))
return
}
if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", nil))
return
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: envOrDefault("INSTAGRAM_OAUTH_TOKEN_URL", "https://api.instagram.com/oauth/access_token"),
ClientID: os.Getenv("INSTAGRAM_APP_ID"),
ClientSecret: os.Getenv("INSTAGRAM_APP_SECRET"),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/instagram/callback",
})
instagramID := firstNonBlank(body["instagram_account_id"], body["user_id"], body["id"])
username := firstNonBlank(body["username"], body["instagram_account_name"], "Instagram")
if err != nil || body["access_token"] == "" || instagramID == "" {
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "400", "error_message": "failed to exchange OAuth token"}))
return
}
inbox, existed, err := upsertInstagramInbox(c, db, accountID, instagramID, username, body)
if err != nil {
c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()}))
return
}
if existed {
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
return
}
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
}
}
func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("TIKTOK_APP_SECRET"))
if !ok {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
if c.Query("error") != "" {
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{
"error_type": firstNonBlank(c.Query("error"), "access_denied"),
"code": c.Query("error_code"),
"error_message": firstNonBlank(c.Query("error_description"), "User cancelled the Authorization"),
}))
return
}
if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", nil))
return
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: envOrDefault("TIKTOK_OAUTH_TOKEN_URL", "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/"),
ClientID: os.Getenv("TIKTOK_APP_ID"),
ClientSecret: os.Getenv("TIKTOK_APP_SECRET"),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/tiktok/callback",
})
businessID := firstNonBlank(body["business_id"], body["tiktok_business_id"], body["advertiser_id"])
if err != nil || body["access_token"] == "" || businessID == "" {
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "failed to exchange OAuth token"}))
return
}
if !tiktokScopesGranted(body["scope"]) {
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "ungranted_scopes", "code": "400", "error_message": "User did not grant all the required scopes"}))
return
}
name := firstNonBlank(body["display_name"], body["username"], "TikTok")
inbox, existed, err := upsertTikTokInbox(c, db, accountID, businessID, name, body)
if err != nil {
c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()}))
return
}
if existed {
c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID))
return
}
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
}
}
func twitterChannelCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(firstNonBlank(c.Query("state"), c.Query("account_id")), os.Getenv("TWITTER_CONSUMER_SECRET"))
if !ok {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
if c.Query("denied") != "" || db == nil || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
return
}
body, err := exchangeTwitterAccessToken(c)
if err != nil || body.Get("oauth_token") == "" || body.Get("user_id") == "" {
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
return
}
inbox, _, err := upsertTwitterInbox(c, db, accountID, body)
if err != nil {
c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil))
return
}
c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID))
}
}
func upsertEmailOAuthInbox(c *gin.Context, db *gorm.DB, accountID uint, email, login, name, imapAddress, provider string, tokenBody map[string]string) (*model.Inbox, bool, error) {
var channel channelmodel.ChannelEmail
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND (imap_login = ? OR email = ?)", accountID, login, email).First(&channel).Error == nil
if existed {
channel.IMAPLogin = login
channel.IMAPAddress = imapAddress
channel.IMAPPort = 993
channel.IMAPEnabled = true
channel.MailboxName = name
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
return nil, false, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
return nil, false, err
}
inbox.Name = name
inbox.ChannelConfig = oauthInboxConfig(provider, tokenBody)
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
}
channel = channelmodel.ChannelEmail{AccountID: accountID, Email: email, MailboxName: name, Domain: emailDomain(email), IMAPEnabled: true, IMAPAddress: imapAddress, IMAPPort: 993, IMAPLogin: login, IMAPSSLMode: "ssl"}
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
return nil, false, err
}
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "email", ChannelID: channel.ID, Enabled: true, ChannelConfig: oauthInboxConfig(provider, tokenBody)}
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
return nil, false, err
}
channel.InboxID = inbox.ID
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
}
func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagramID, username string, body map[string]string) (*model.Inbox, bool, error) {
var channel channelmodel.ChannelInstagram
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND instagram_account_id = ?", accountID, instagramID).First(&channel).Error == nil
if existed {
channel.PageAccessToken = body["access_token"]
channel.InstagramAccountName = username
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
return nil, false, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
return nil, false, err
}
inbox.Name = username
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
}
channel = channelmodel.ChannelInstagram{AccountID: accountID, InstagramAccountID: instagramID, InstagramBusinessAccountID: body["instagram_business_account_id"], PageAccessToken: body["access_token"], ConnectedFBPageID: firstNonBlank(body["connected_fb_page_id"], body["page_id"]), InstagramAccountName: username}
if channel.ConnectedFBPageID == "" {
channel.ConnectedFBPageID = instagramID
}
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
return nil, false, err
}
inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true}
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
return nil, false, err
}
channel.InboxID = inbox.ID
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
}
func upsertTikTokInbox(c *gin.Context, db *gorm.DB, accountID uint, businessID, name string, body map[string]string) (*model.Inbox, bool, error) {
var channel channelmodel.ChannelTikTok
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND tiktok_business_id = ?", accountID, businessID).First(&channel).Error == nil
expiresAt := time.Now().UTC().Add(time.Duration(parseIntDefault(body["expires_in"], 3600)) * time.Second)
if existed {
channel.AccessToken = body["access_token"]
channel.RefreshToken = body["refresh_token"]
channel.TokenExpiresAt = expiresAt
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
return nil, false, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
return nil, false, err
}
inbox.Name = name
return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error
}
channel = channelmodel.ChannelTikTok{AccountID: accountID, TikTokBusinessID: businessID, AccessToken: body["access_token"], RefreshToken: body["refresh_token"], TokenExpiresAt: expiresAt}
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
return nil, false, err
}
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true}
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
return nil, false, err
}
channel.InboxID = inbox.ID
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
}
func upsertTwitterInbox(c *gin.Context, db *gorm.DB, accountID uint, values url.Values) (*model.Inbox, bool, error) {
var channel channelmodel.ChannelTwitter
existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND twitter_user_id = ?", accountID, values.Get("user_id")).First(&channel).Error == nil
name := firstNonBlank(values.Get("screen_name"), values.Get("name"), "Twitter")
if existed {
channel.TwitterAccessToken = values.Get("oauth_token")
channel.TwitterAccessTokenSecret = values.Get("oauth_token_secret")
channel.ScreenName = values.Get("screen_name")
channel.Name = name
if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil {
return nil, false, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil {
return nil, false, err
}
return &inbox, true, nil
}
channel = channelmodel.ChannelTwitter{AccountID: accountID, TwitterUserID: values.Get("user_id"), TwitterAccessToken: values.Get("oauth_token"), TwitterAccessTokenSecret: values.Get("oauth_token_secret"), ScreenName: values.Get("screen_name"), Name: name, AccessToken: values.Get("oauth_token")}
if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil {
return nil, false, err
}
inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true}
if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil {
return nil, false, err
}
channel.InboxID = inbox.ID
return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error
}
func parseJWTClaimsUnverified(tokenString string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
_, _, err := jwt.NewParser().ParseUnverified(tokenString, claims)
return claims, err
}
func claimString(claims jwt.MapClaims, key string) string {
if value, ok := claims[key].(string); ok {
return value
}
return ""
}
func oauthInboxConfig(provider string, tokenBody map[string]string) string {
encoded, _ := json.Marshal(map[string]any{"provider": provider, "provider_config": compactStringMap(tokenBody)})
return string(encoded)
}
func compactStringMap(values map[string]string) map[string]string {
result := make(map[string]string, len(values))
for key, value := range values {
if value != "" {
result[key] = value
}
}
return result
}
func exchangeTwitterAccessToken(c *gin.Context) (url.Values, error) {
form := url.Values{}
form.Set("oauth_token", c.Query("oauth_token"))
form.Set("oauth_verifier", c.Query("oauth_verifier"))
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, envOrDefault("TWITTER_OAUTH_TOKEN_URL", "https://api.twitter.com/oauth/access_token"), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("twitter access token failed: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return url.ParseQuery(string(body))
}
func tiktokScopesGranted(scope string) bool {
if strings.TrimSpace(scope) == "" {
return true
}
granted := map[string]bool{}
for _, item := range strings.Split(scope, ",") {
granted[strings.TrimSpace(item)] = true
}
for _, required := range []string{"user.info.basic", "user.info.username", "user.info.stats", "user.info.profile", "user.account.type", "user.insights", "message.list.read", "message.list.send", "message.list.manage"} {
if !granted[required] {
return false
}
}
return true
}
func inboxAgentsURL(accountID uint, inboxID uint) string {
return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%d/agents", frontendBaseURL(), accountID, inboxID)
}
func inboxSettingsURL(accountID uint, inboxID uint) string {
return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/%d", frontendBaseURL(), accountID, inboxID)
}
func newInboxURL(accountID uint, channel string, query map[string]string) string {
base := fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%s", frontendBaseURL(), accountID, channel)
if len(query) == 0 {
return base
}
values := url.Values{}
for key, value := range query {
if value != "" {
values.Set(key, value)
}
}
if len(values) == 0 {
return base
}
return base + "?" + values.Encode()
}
func emailDomain(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
func parseIntDefault(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}