feat(custom-domains): expose cloudflare challenge

This commit is contained in:
2026-06-06 09:09:01 +08:00
parent 361e3b8311
commit c4b92b7a18
6 changed files with 128 additions and 8 deletions
+55
View File
@@ -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, "[]")
}
+60
View File
@@ -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
}