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" "github.com/golang-jwt/jwt/v5" "gorm.io/datatypes" "gorm.io/gorm" ) func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET")) redirectURL := integrationRedirectURL("linear", accountID) if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, redirectURL) return } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"), ClientID: os.Getenv("LINEAR_CLIENT_ID"), ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"), Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/linear/callback", }) if err != nil || strings.TrimSpace(body["access_token"]) == "" { c.Redirect(http.StatusFound, redirectURL) return } settings := map[string]any{ "token_type": body["token_type"], "scope": body["scope"], "refresh_token": body["refresh_token"], } if body["expires_in"] != "" { settings["expires_in"] = body["expires_in"] if seconds, err := strconv.Atoi(body["expires_in"]); err == nil { settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) } } if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil { c.Redirect(http.StatusFound, redirectURL) return } c.Redirect(http.StatusFound, redirectURL) } } func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET")) redirectURL := integrationRedirectURL("shopify", accountID) shop := strings.TrimSpace(c.Query("shop")) if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, redirectWithError(redirectURL)) return } tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL") if tokenURL == "" { tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop) } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: tokenURL, ClientID: os.Getenv("SHOPIFY_CLIENT_ID"), ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"), Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/shopify/callback", }) if err != nil || strings.TrimSpace(body["access_token"]) == "" { c.Redirect(http.StatusFound, redirectWithError(redirectURL)) return } if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil { c.Redirect(http.StatusFound, redirectWithError(redirectURL)) return } c.Redirect(http.StatusFound, redirectURL) } } func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET")) redirectURL := integrationRedirectURL("notion", accountID) if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, frontendBaseURL()) return } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"), ClientID: os.Getenv("NOTION_CLIENT_ID"), ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"), Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/notion/callback", BasicAuth: true, }) if err != nil || strings.TrimSpace(body["access_token"]) == "" { c.Redirect(http.StatusFound, frontendBaseURL()) return } settings := map[string]any{ "token_type": body["token_type"], "workspace_name": body["workspace_name"], "workspace_id": body["workspace_id"], "workspace_icon": body["workspace_icon"], "bot_id": body["bot_id"], } if owner := strings.TrimSpace(body["owner"]); owner != "" { settings["owner"] = owner } if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil { c.Redirect(http.StatusFound, frontendBaseURL()) return } c.Redirect(http.StatusFound, redirectURL) } } type oauthTokenExchangeRequest struct { TokenURL string ClientID string ClientSecret string Code string RedirectURI string BasicAuth bool } func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) { form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", req.Code) form.Set("redirect_uri", req.RedirectURI) if !req.BasicAuth { form.Set("client_id", req.ClientID) form.Set("client_secret", req.ClientSecret) } httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpReq.Header.Set("Accept", "application/json") if req.BasicAuth { httpReq.SetBasicAuth(req.ClientID, req.ClientSecret) } resp, err := http.DefaultClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { _, _ = io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status) } var raw map[string]any if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, err } result := make(map[string]string, len(raw)) for key, value := range raw { switch typed := value.(type) { case string: result[key] = typed case float64: result[key] = strconv.FormatInt(int64(typed), 10) case nil: result[key] = "" default: encoded, _ := json.Marshal(typed) result[key] = string(encoded) } } return result, nil } func callbackAccountID(state string, secret string) (uint, bool) { state = strings.TrimSpace(state) if state == "" { return 0, false } if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 { return uint(id), true } if secret == "" { return 0, false } claims := jwt.MapClaims{} token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method") } return []byte(secret), nil }) if err != nil || !token.Valid { return 0, false } if sub, ok := claims["sub"].(float64); ok && sub > 0 { return uint(sub), true } if sub, ok := claims["sub"].(string); ok { id, err := strconv.ParseUint(sub, 10, 64) return uint(id), err == nil && id > 0 } return 0, false } func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool { var count int64 if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil { return false } return count > 0 } func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error { settingsJSON, err := json.Marshal(compactMap(settings)) if err != nil { return err } var hook model.IntegrationHook err = db.WithContext(c.Request.Context()). Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType). First(&hook).Error if err == nil { hook.AppID = string(hookType) hook.HookType = hookType hook.Status = model.HookStatusActive hook.ReferenceID = referenceID hook.AccessToken = accessToken hook.Settings = datatypes.JSON(settingsJSON) return db.WithContext(c.Request.Context()).Save(&hook).Error } if err != gorm.ErrRecordNotFound { return err } return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings) } func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error { settingsJSON, err := json.Marshal(compactMap(settings)) if err != nil { return err } hook := &model.IntegrationHook{ AccountID: accountID, AppID: string(hookType), HookType: hookType, Status: model.HookStatusActive, AccessToken: accessToken, ReferenceID: referenceID, Settings: datatypes.JSON(settingsJSON), } return db.WithContext(c.Request.Context()).Create(hook).Error } func compactMap(values map[string]any) map[string]any { result := make(map[string]any, len(values)) for key, value := range values { if value == nil || value == "" { continue } result[key] = value } return result } func integrationRedirectURL(app string, accountID uint) string { if accountID == 0 { return frontendBaseURL() } return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app) } func redirectWithError(location string) string { if strings.Contains(location, "?") { return location + "&error=true" } return location + "?error=true" } func frontendBaseURL() string { return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/") } func envOrDefault(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback }