mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/relay"
|
|
)
|
|
|
|
const onboardingSchemaVersion = "pulse-mobile-onboarding-v1"
|
|
|
|
type onboardingRelayDetails struct {
|
|
Enabled bool `json:"enabled"`
|
|
URL string `json:"url"`
|
|
IdentityFingerprint string `json:"identity_fingerprint,omitempty"`
|
|
IdentityPublicKey string `json:"identity_public_key,omitempty"`
|
|
}
|
|
|
|
type onboardingQRResponse struct {
|
|
Schema string `json:"schema"`
|
|
InstanceURL string `json:"instance_url"`
|
|
InstanceID string `json:"instance_id,omitempty"`
|
|
Relay onboardingRelayDetails `json:"relay"`
|
|
AuthToken string `json:"auth_token"`
|
|
DeepLink string `json:"deep_link"`
|
|
Diagnostics []onboardingDiagnostic `json:"diagnostics"`
|
|
}
|
|
|
|
type onboardingDeepLinkResponse struct {
|
|
URL string `json:"url"`
|
|
Diagnostics []onboardingDiagnostic `json:"diagnostics"`
|
|
}
|
|
|
|
type onboardingValidationRequest struct {
|
|
InstanceID string `json:"instance_id"`
|
|
RelayURL string `json:"relay_url"`
|
|
AuthToken string `json:"auth_token"`
|
|
}
|
|
|
|
type onboardingValidationResponse struct {
|
|
Success bool `json:"success"`
|
|
Diagnostics []onboardingDiagnostic `json:"diagnostics"`
|
|
}
|
|
|
|
type onboardingDiagnostic struct {
|
|
Code string `json:"code"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Field string `json:"field,omitempty"`
|
|
Expected string `json:"expected,omitempty"`
|
|
Received string `json:"received,omitempty"`
|
|
}
|
|
|
|
func emptyOnboardingQRResponse() onboardingQRResponse {
|
|
return onboardingQRResponse{}.normalizeCollections()
|
|
}
|
|
|
|
func (r onboardingQRResponse) normalizeCollections() onboardingQRResponse {
|
|
r.Relay = r.Relay.normalizeCollections()
|
|
if r.Diagnostics == nil {
|
|
r.Diagnostics = []onboardingDiagnostic{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func emptyOnboardingDeepLinkResponse() onboardingDeepLinkResponse {
|
|
return onboardingDeepLinkResponse{}.normalizeCollections()
|
|
}
|
|
|
|
func (r onboardingDeepLinkResponse) normalizeCollections() onboardingDeepLinkResponse {
|
|
if r.Diagnostics == nil {
|
|
r.Diagnostics = []onboardingDiagnostic{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func emptyOnboardingValidationResponse() onboardingValidationResponse {
|
|
return onboardingValidationResponse{}.normalizeCollections()
|
|
}
|
|
|
|
func (r onboardingValidationResponse) normalizeCollections() onboardingValidationResponse {
|
|
if r.Diagnostics == nil {
|
|
r.Diagnostics = []onboardingDiagnostic{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (d onboardingRelayDetails) normalizeCollections() onboardingRelayDetails {
|
|
return d
|
|
}
|
|
|
|
func (r *Router) handleGetOnboardingQR(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
relayCfg, err := r.loadRelayConfigForOnboarding()
|
|
if err != nil {
|
|
http.Error(w, "failed to load relay config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
payload, diagnostics := r.buildOnboardingPayload(req, relayCfg, onboardingAuthTokenFromRequest(req))
|
|
if len(diagnostics) > 0 {
|
|
payload.Diagnostics = diagnostics
|
|
}
|
|
payload = payload.normalizeCollections()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func (r *Router) handleValidateOnboardingConnection(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var payload onboardingValidationRequest
|
|
decoder := json.NewDecoder(req.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&payload); err != nil {
|
|
http.Error(w, "Invalid request payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
payload.InstanceID = strings.TrimSpace(payload.InstanceID)
|
|
payload.RelayURL = strings.TrimSpace(payload.RelayURL)
|
|
payload.AuthToken = strings.TrimSpace(payload.AuthToken)
|
|
|
|
relayCfg, err := r.loadRelayConfigForOnboarding()
|
|
if err != nil {
|
|
http.Error(w, "failed to load relay config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
diagnostics := make([]onboardingDiagnostic, 0, 8)
|
|
|
|
if !relayCfg.Enabled {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "relay_disabled",
|
|
Severity: "error",
|
|
Field: "relay_url",
|
|
Message: "Relay is disabled on this instance.",
|
|
})
|
|
}
|
|
|
|
if payload.InstanceID == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "instance_id_missing",
|
|
Severity: "error",
|
|
Field: "instance_id",
|
|
Message: "instance_id is required.",
|
|
})
|
|
} else {
|
|
expectedInstanceID := r.currentRelayInstanceID()
|
|
if expectedInstanceID == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "instance_id_unverifiable",
|
|
Severity: "warning",
|
|
Field: "instance_id",
|
|
Message: "Relay is not currently connected; instance_id cannot be verified.",
|
|
})
|
|
} else if payload.InstanceID != expectedInstanceID {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "instance_id_mismatch",
|
|
Severity: "error",
|
|
Field: "instance_id",
|
|
Expected: expectedInstanceID,
|
|
Received: payload.InstanceID,
|
|
Message: fmt.Sprintf("instance_id does not match the connected relay instance (%s).", expectedInstanceID),
|
|
})
|
|
}
|
|
}
|
|
|
|
if payload.RelayURL == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "relay_url_missing",
|
|
Severity: "error",
|
|
Field: "relay_url",
|
|
Message: "relay_url is required.",
|
|
})
|
|
} else {
|
|
providedRelayURL, providedOK := normalizeRelayURL(payload.RelayURL)
|
|
if !providedOK {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "relay_url_invalid",
|
|
Severity: "error",
|
|
Field: "relay_url",
|
|
Received: payload.RelayURL,
|
|
Message: "relay_url must be a valid ws:// or wss:// URL.",
|
|
})
|
|
} else {
|
|
expectedRelayURL, expectedOK := normalizeRelayURL(normalizeOnboardingRelayAppURL(relayCfg.ServerURL))
|
|
if expectedOK && expectedRelayURL != providedRelayURL {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "relay_url_mismatch",
|
|
Severity: "error",
|
|
Field: "relay_url",
|
|
Expected: expectedRelayURL,
|
|
Received: providedRelayURL,
|
|
Message: "relay_url does not match the configured relay server URL.",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if payload.AuthToken == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "auth_token_missing",
|
|
Severity: "error",
|
|
Field: "auth_token",
|
|
Message: "auth_token is required.",
|
|
})
|
|
} else if !r.validateOnboardingAuthToken(payload.AuthToken) {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "auth_token_invalid",
|
|
Severity: "error",
|
|
Field: "auth_token",
|
|
Message: "auth_token is not valid for this Pulse instance.",
|
|
})
|
|
}
|
|
|
|
response := onboardingValidationResponse{
|
|
Success: !hasOnboardingError(diagnostics),
|
|
Diagnostics: diagnostics,
|
|
}.normalizeCollections()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (r *Router) handleGetOnboardingDeepLink(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
relayCfg, err := r.loadRelayConfigForOnboarding()
|
|
if err != nil {
|
|
http.Error(w, "failed to load relay config", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
payload, diagnostics := r.buildOnboardingPayload(req, relayCfg, onboardingAuthTokenFromRequest(req))
|
|
response := onboardingDeepLinkResponse{
|
|
URL: payload.DeepLink,
|
|
Diagnostics: diagnostics,
|
|
}.normalizeCollections()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Config, authToken string) (onboardingQRResponse, []onboardingDiagnostic) {
|
|
if relayCfg == nil {
|
|
relayCfg = relay.DefaultConfig()
|
|
}
|
|
|
|
relayURL := strings.TrimSpace(relayCfg.ServerURL)
|
|
if relayURL == "" {
|
|
relayURL = relay.DefaultServerURL
|
|
}
|
|
mobileRelayURL := normalizeOnboardingRelayAppURL(relayURL)
|
|
|
|
payload := emptyOnboardingQRResponse()
|
|
payload.Schema = onboardingSchemaVersion
|
|
payload.InstanceURL = strings.TrimSpace(r.resolvePublicURL(req))
|
|
payload.InstanceID = r.currentRelayInstanceID()
|
|
payload.Relay = onboardingRelayDetails{
|
|
Enabled: relayCfg.Enabled,
|
|
URL: mobileRelayURL,
|
|
IdentityFingerprint: strings.TrimSpace(relayCfg.IdentityFingerprint),
|
|
IdentityPublicKey: strings.TrimSpace(relayCfg.IdentityPublicKey),
|
|
}.normalizeCollections()
|
|
payload.AuthToken = authToken
|
|
payload.DeepLink = buildOnboardingDeepLink(payload)
|
|
|
|
diagnostics := make([]onboardingDiagnostic, 0, 4)
|
|
if !payload.Relay.Enabled {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "relay_disabled",
|
|
Severity: "warning",
|
|
Field: "relay.enabled",
|
|
Message: "Relay is disabled. Hosted mobile connection will not work until relay is enabled.",
|
|
})
|
|
}
|
|
if payload.InstanceID == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "instance_id_unavailable",
|
|
Severity: "warning",
|
|
Field: "instance_id",
|
|
Message: "Relay instance_id is not available until relay registration succeeds.",
|
|
})
|
|
}
|
|
if payload.AuthToken == "" {
|
|
diagnostics = append(diagnostics, onboardingDiagnostic{
|
|
Code: "auth_token_missing",
|
|
Severity: "warning",
|
|
Field: "auth_token",
|
|
Message: "No API token was provided in the request. Include X-API-Token or Authorization: Bearer for QR bootstrap payloads.",
|
|
})
|
|
}
|
|
return payload, diagnostics
|
|
}
|
|
|
|
func (r *Router) loadRelayConfigForOnboarding() (*relay.Config, error) {
|
|
return r.loadRelayConfigForRuntime(context.Background())
|
|
}
|
|
|
|
func (r *Router) currentRelayInstanceID() string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
r.relayMu.RLock()
|
|
client := r.relayClient
|
|
r.relayMu.RUnlock()
|
|
if client == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(client.Status().InstanceID)
|
|
}
|
|
|
|
func (r *Router) validateOnboardingAuthToken(token string) bool {
|
|
if r == nil || r.config == nil {
|
|
return false
|
|
}
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return false
|
|
}
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
return r.config.IsValidAPIToken(token)
|
|
}
|
|
|
|
func onboardingAuthTokenFromRequest(req *http.Request) string {
|
|
if req == nil {
|
|
return ""
|
|
}
|
|
if token := strings.TrimSpace(req.Header.Get("X-API-Token")); token != "" {
|
|
return token
|
|
}
|
|
if token := extractBearerToken(req.Header.Get("Authorization")); token != "" {
|
|
return token
|
|
}
|
|
if token := strings.TrimSpace(req.URL.Query().Get("auth_token")); token != "" {
|
|
return token
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeRelayURL(raw string) (string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", false
|
|
}
|
|
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
parsed.Scheme = strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
|
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
|
|
return "", false
|
|
}
|
|
|
|
parsed.Host = strings.ToLower(strings.TrimSpace(parsed.Host))
|
|
if parsed.Host == "" {
|
|
return "", false
|
|
}
|
|
|
|
parsed.RawFragment = ""
|
|
parsed.Fragment = ""
|
|
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
|
|
if parsed.Path == "/" {
|
|
parsed.Path = ""
|
|
}
|
|
|
|
return parsed.String(), true
|
|
}
|
|
|
|
func normalizeOnboardingRelayAppURL(raw string) string {
|
|
normalized, ok := normalizeRelayURL(raw)
|
|
if !ok {
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
parsed, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return normalized
|
|
}
|
|
|
|
path := strings.TrimSuffix(parsed.Path, "/")
|
|
switch {
|
|
case path == "":
|
|
parsed.Path = "/ws/app"
|
|
case path == "/ws/instance":
|
|
parsed.Path = "/ws/app"
|
|
}
|
|
|
|
return parsed.String()
|
|
}
|
|
|
|
func hasOnboardingError(diagnostics []onboardingDiagnostic) bool {
|
|
for _, diagnostic := range diagnostics {
|
|
if diagnostic.Severity == "error" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildOnboardingDeepLink(payload onboardingQRResponse) string {
|
|
query := url.Values{}
|
|
query.Set("schema", payload.Schema)
|
|
query.Set("instance_url", payload.InstanceURL)
|
|
if payload.InstanceID != "" {
|
|
query.Set("instance_id", payload.InstanceID)
|
|
}
|
|
if payload.Relay.URL != "" {
|
|
query.Set("relay_url", payload.Relay.URL)
|
|
}
|
|
if payload.AuthToken != "" {
|
|
query.Set("auth_token", payload.AuthToken)
|
|
}
|
|
if payload.Relay.IdentityFingerprint != "" {
|
|
query.Set("identity_fingerprint", payload.Relay.IdentityFingerprint)
|
|
}
|
|
if payload.Relay.IdentityPublicKey != "" {
|
|
query.Set("identity_public_key", payload.Relay.IdentityPublicKey)
|
|
}
|
|
return (&url.URL{
|
|
Scheme: "pulse",
|
|
Host: "connect",
|
|
RawQuery: query.Encode(),
|
|
}).String()
|
|
}
|