Files
gochat/backend/internal/router/integration_callbacks.go
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

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
}