174 lines
6.7 KiB
Go
174 lines
6.7 KiB
Go
// Command server runs the gateway: the BFF that terminates the public
|
|
// HTTP/HTMX surface and composes it from every other bounded context
|
|
// over gRPC. Replaces the net/http wiring half of the monolith's
|
|
// cmd/server/main.go.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
agentv1 "enkly/gen/agent/v1"
|
|
billingv1 "enkly/gen/billing/v1"
|
|
conversationv1 "enkly/gen/conversation/v1"
|
|
iamv1 "enkly/gen/iam/v1"
|
|
knowledgev1 "enkly/gen/knowledge/v1"
|
|
workspacev1 "enkly/gen/workspace/v1"
|
|
"enkly/pkg/envkit"
|
|
"enkly/services/gateway/internal/handlers"
|
|
)
|
|
|
|
func dial(addr string, extraOpts ...grpc.DialOption) *grpc.ClientConn {
|
|
opts := append([]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, extraOpts...)
|
|
conn, err := grpc.NewClient(addr, opts...)
|
|
if err != nil {
|
|
panic(err) // unreachable at startup is fatal; see main()'s recover-free startup sequence below
|
|
}
|
|
return conn
|
|
}
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
|
|
|
if err := envkit.LoadDotEnv(".env"); err != nil {
|
|
logger.Error("load .env", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var (
|
|
httpAddr = envkit.String("ENKLY_GATEWAY_HTTP_ADDR", ":8080")
|
|
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
|
|
|
|
templatesDir = envkit.String("ENKLY_TEMPLATES_DIR", "web/templates")
|
|
staticDir = envkit.String("ENKLY_STATIC_DIR", "web/static")
|
|
|
|
iamAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", "iam:9001")
|
|
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
|
|
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
|
|
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
|
|
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
|
|
agentAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", "agent:9006")
|
|
|
|
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
|
|
)
|
|
|
|
templates, err := handlers.LoadTemplates(templatesDir)
|
|
if err != nil {
|
|
logger.Error("load templates", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
bigMsg := grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20))
|
|
h := &handlers.Handlers{
|
|
IAM: iamv1.NewIAMServiceClient(dial(iamAddr)),
|
|
Billing: billingv1.NewBillingServiceClient(dial(billingAddr)),
|
|
Conversation: conversationv1.NewConversationServiceClient(dial(conversationAddr)),
|
|
Workspace: workspacev1.NewWorkspaceServiceClient(dial(workspaceAddr, bigMsg)),
|
|
Knowledge: knowledgev1.NewKnowledgeServiceClient(dial(knowledgeAddr)),
|
|
Agent: agentv1.NewAgentServiceClient(dial(agentAddr, bigMsg)),
|
|
Templates: templates,
|
|
Logger: logger,
|
|
BaseURL: baseURL,
|
|
StripePublishKey: stripePublishKey,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// --- Health check (probed by k8s readinessProbe/livenessProbe in
|
|
// k8s/27-gateway.yaml at GET /healthz — must stay unauthenticated
|
|
// and dependency-free so a downstream gRPC outage doesn't also
|
|
// take the gateway pod itself out of rotation). ---
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// --- Public (no auth) ---
|
|
mux.HandleFunc("GET /login", h.LoginPage)
|
|
mux.HandleFunc("POST /login", h.RequireCSRF(h.Login))
|
|
mux.HandleFunc("GET /signup", h.SignupPage)
|
|
mux.HandleFunc("POST /signup", h.RequireCSRF(h.Signup))
|
|
mux.HandleFunc("GET /verify-email", h.VerifyEmailPage)
|
|
mux.HandleFunc("POST /verify-email", h.RequireCSRF(h.VerifyEmail))
|
|
mux.HandleFunc("POST /verify-email/resend", h.RequireCSRF(h.ResendVerification))
|
|
mux.HandleFunc("GET /forgot-password", h.ForgotPasswordPage)
|
|
mux.HandleFunc("POST /forgot-password", h.RequireCSRF(h.ForgotPassword))
|
|
mux.HandleFunc("GET /reset-password", h.ResetPasswordPage)
|
|
mux.HandleFunc("POST /reset-password", h.RequireCSRF(h.ResetPassword))
|
|
mux.HandleFunc("POST /logout", h.Logout)
|
|
mux.HandleFunc("POST /webhooks/stripe", h.StripeWebhook)
|
|
mux.HandleFunc("POST /theme", h.SetTheme)
|
|
mux.HandleFunc("POST /language", h.SetLanguage)
|
|
|
|
// --- Authenticated ---
|
|
mux.HandleFunc("GET /{$}", h.RequireAuth(h.Index))
|
|
mux.HandleFunc("GET /c/{id}", h.RequireAuth(h.SwitchConversation))
|
|
mux.HandleFunc("POST /conversations/new", h.RequireAuth(h.RequireCSRF(h.NewConversation)))
|
|
mux.HandleFunc("POST /conversations/{id}/rename", h.RequireAuth(h.RequireCSRF(h.RenameConversation)))
|
|
mux.HandleFunc("POST /conversations/{id}/delete", h.RequireAuth(h.RequireCSRF(h.DeleteConversationHandler)))
|
|
|
|
mux.HandleFunc("POST /chat", h.RequireAuth(h.RequireCSRF(h.Chat)))
|
|
mux.HandleFunc("GET /chat/stream/{id}", h.RequireAuth(h.StreamChat))
|
|
mux.HandleFunc("POST /chat/stream/{id}/stop", h.RequireAuth(h.RequireCSRF(h.StopStream)))
|
|
|
|
mux.HandleFunc("GET /tree", h.RequireAuth(h.FileTree))
|
|
mux.HandleFunc("GET /status", h.RequireAuth(h.StatusBar))
|
|
|
|
mux.HandleFunc("GET /files", h.RequireAuth(h.FilesPanel))
|
|
mux.HandleFunc("POST /files/upload", h.RequireAuth(h.RequireCSRFUpload(h.UploadFile)))
|
|
mux.HandleFunc("POST /files/attach", h.RequireAuth(h.RequireCSRFUpload(h.AttachFile)))
|
|
mux.HandleFunc("GET /files/download/{path...}", h.RequireAuth(h.DownloadFile))
|
|
|
|
mux.HandleFunc("GET /subscription", h.RequireAuth(h.SubscriptionPage))
|
|
mux.HandleFunc("POST /subscription/checkout", h.RequireAuth(h.RequireCSRF(h.CreateCheckout)))
|
|
mux.HandleFunc("POST /subscription/portal", h.RequireAuth(h.RequireCSRF(h.BillingPortal)))
|
|
mux.HandleFunc("GET /subscription/success", h.RequireAuth(h.SubscriptionSuccess))
|
|
mux.HandleFunc("GET /subscription/cancel", h.RequireAuth(h.SubscriptionCancel))
|
|
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
|
|
|
// Shared edge middleware — security headers, gzip, structured
|
|
// request logging, panic recovery — applied around the whole mux,
|
|
// identical ordering to the monolith's.
|
|
root := handlers.Chain(mux,
|
|
handlers.Recover(logger),
|
|
handlers.RequestLogger(logger),
|
|
handlers.SecurityHeaders,
|
|
handlers.Gzip,
|
|
)
|
|
|
|
lis, err := net.Listen("tcp", httpAddr)
|
|
if err != nil {
|
|
logger.Error("listen", "addr", httpAddr, "err", err)
|
|
os.Exit(1)
|
|
}
|
|
srv := &http.Server{
|
|
Handler: root,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
logger.Info("gateway listening", "addr", httpAddr, "base_url", baseURL)
|
|
if err := srv.Serve(lis); err != nil && err != http.ErrServerClosed {
|
|
logger.Error("serve", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
<-ctx.Done()
|
|
logger.Info("shutting down")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
}
|