feat(custom-domains): expose cloudflare challenge
This commit is contained in:
@@ -2,7 +2,9 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/handler/widget"
|
||||
ws "github.com/gochat/gochat/internal/handler/ws"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -199,6 +202,7 @@ func RegisterRoutes(
|
||||
engine.GET("/.well-known/assetlinks.json", androidAssetlinks)
|
||||
engine.GET("/.well-known/apple-app-site-association", appleAppSiteAssociation)
|
||||
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
|
||||
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
||||
|
||||
// Dashboard shell routes used by Chatwoot mailer and push deep links.
|
||||
// Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index.
|
||||
@@ -2074,3 +2078,54 @@ func microsoftIdentityAssociation(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func customDomainChallenge(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if db == nil {
|
||||
c.String(http.StatusNotFound, "Domain not found")
|
||||
return
|
||||
}
|
||||
|
||||
var portal model.Portal
|
||||
domain := requestHost(c.Request)
|
||||
err := db.WithContext(c.Request.Context()).
|
||||
Select("id, custom_domain, ssl_settings").
|
||||
Where("custom_domain = ?", domain).
|
||||
First(&portal).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.String(http.StatusNotFound, "Domain not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
settings := map[string]any{}
|
||||
if len(portal.SSLSettings) > 0 {
|
||||
_ = json.Unmarshal(portal.SSLSettings, &settings)
|
||||
}
|
||||
if sslSettingString(settings, "cf_verification_id") != c.Param("id") {
|
||||
c.String(http.StatusNotFound, "Challenge ID not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, sslSettingString(settings, "cf_verification_body"))
|
||||
}
|
||||
}
|
||||
|
||||
func sslSettingString(settings map[string]any, key string) string {
|
||||
value, _ := settings[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func requestHost(req *http.Request) string {
|
||||
host := strings.TrimSpace(req.Host)
|
||||
if host == "" && req.URL != nil {
|
||||
host = strings.TrimSpace(req.URL.Host)
|
||||
}
|
||||
if stripped, _, err := net.SplitHostPort(host); err == nil {
|
||||
return stripped
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
@@ -38,6 +42,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"GET /.well-known/assetlinks.json",
|
||||
"GET /.well-known/apple-app-site-association",
|
||||
"GET /.well-known/microsoft-identity-association.json",
|
||||
"GET /.well-known/cf-custom-hostname-challenge/:id",
|
||||
"GET /app",
|
||||
"GET /app/*params",
|
||||
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
|
||||
@@ -73,6 +78,39 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupRouterPortalDB(t)
|
||||
portal := &model.Portal{
|
||||
AccountID: 1,
|
||||
Name: "Help Center",
|
||||
Slug: "help-center",
|
||||
CustomDomain: "help.example.com",
|
||||
SSLSettings: json.RawMessage(`{"cf_verification_id":"challenge-token","cf_verification_body":"cloudflare-body"}`),
|
||||
}
|
||||
if err := db.Create(portal).Error; err != nil {
|
||||
t.Fatalf("failed to create portal: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
||||
|
||||
matched := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "help.example.com:3000")
|
||||
if matched.Code != http.StatusOK || matched.Body.String() != "cloudflare-body" {
|
||||
t.Fatalf("expected matching challenge body, got %d %q", matched.Code, matched.Body.String())
|
||||
}
|
||||
|
||||
missingDomain := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "missing.example.com")
|
||||
if missingDomain.Code != http.StatusNotFound || missingDomain.Body.String() != "Domain not found" {
|
||||
t.Fatalf("expected domain 404, got %d %q", missingDomain.Code, missingDomain.Body.String())
|
||||
}
|
||||
|
||||
wrongChallenge := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/wrong-token", "help.example.com")
|
||||
if wrongChallenge.Code != http.StatusNotFound || wrongChallenge.Body.String() != "Challenge ID not found" {
|
||||
t.Fatalf("expected challenge 404, got %d %q", wrongChallenge.Code, wrongChallenge.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
|
||||
@@ -167,3 +205,25 @@ func performGet(engine *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||
engine.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func performHostGet(engine *gin.Engine, path string, host string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = host
|
||||
engine.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Portal{}); err != nil {
|
||||
t.Fatalf("failed to migrate portal: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user