312 lines
9.5 KiB
Go
312 lines
9.5 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"
|
|
"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
|
|
}
|