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 "" }