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.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user