commit 32eb9865650ecde0e13820bd8ec492f17231be2c Author: Fendy Date: Wed Jul 15 22:50:29 2026 +0800 migrate from github diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab9726b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +dist-ssr +*.log +*.local +.DS_Store +.git +.vscode +.idea +pkg/gen/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd54478 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.24-alpine AS dev +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +EXPOSE 1234 + +CMD ["sh", "-c", "go mod download && go run main.go"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..3cbb93d --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,31 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app + +ENV GOPROXY=https://goproxy.cn,direct + +RUN apk add --no-cache protobuf +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.5 && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +COPY collab-go/go.mod collab-go/go.sum ./ +COPY collab-go ./collab-go +COPY proto ./proto + +WORKDIR /app/collab-go +RUN go mod download +RUN mkdir -p /app/collab-go/pkg/gen && \ + protoc -I /app/proto \ + --go_out=/app/collab-go/pkg/gen --go_opt=paths=source_relative \ + --go-grpc_out=/app/collab-go/pkg/gen --go-grpc_opt=paths=source_relative \ + /app/proto/yoresee_doc/v1/yoresee_doc.proto +RUN go mod tidy +RUN CGO_ENABLED=0 go build -p 1 -o collab-go main.go + +FROM alpine:latest AS runner +WORKDIR /app + +COPY --from=builder /app/collab-go . + +EXPOSE 1234 + +CMD ["./collab-go"] diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..8ac2946 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,34 @@ +package auth + +import ( + "net/http" + + "github.com/golang-jwt/jwt/v5" +) + +type Authenticator struct { + secret string +} + +func NewAuthenticator(secret string) *Authenticator { + return &Authenticator{ + secret: secret, + } +} + +func (a *Authenticator) ValidateToken(tokenString string) error { + if tokenString == "" { + return http.ErrNoCookie + } + + if a.secret != "" { + _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(a.secret), nil + }, jwt.WithValidMethods([]string{"HS256", "HS384", "HS512"})) + return err + } + + // TODO: jwt redis support + _, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + return err +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0b91b1a --- /dev/null +++ b/config/config.go @@ -0,0 +1,19 @@ +package config + +import "github.com/caarlos0/env/v11" + +type Config struct { + Addr string `env:"ADDR" envDefault:":1234"` + JWTSecret string `env:"JWT_SECRET"` + CollabCoreURL string `env:"COLLAB_CORE_URL" envDefault:"ws://collab-core:1234"` + BackendGRPCAddr string `env:"BACKEND_GRPC_ADDR" envDefault:"backend:9090"` + InternalRPCKey string `env:"INTERNAL_RPC_KEY"` +} + +func Load() (*Config, error) { + cfg := &Config{} + if err := env.Parse(cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1567359 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/XingfenD/yoresee_doc/collab-go + +go 1.24.0 + +toolchain go1.24.1 + +require ( + github.com/caarlos0/env/v11 v11.4.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.1 + google.golang.org/grpc v1.79.2 + google.golang.org/protobuf v1.36.10 +) + +require ( + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7ea8457 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/handler/ws.go b/handler/ws.go new file mode 100644 index 0000000..82c65bd --- /dev/null +++ b/handler/ws.go @@ -0,0 +1,109 @@ +package handler + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/grpc/metadata" + + "github.com/XingfenD/yoresee_doc/collab-go/auth" + yoreseedocpb "github.com/XingfenD/yoresee_doc/collab-go/pkg/gen/yoresee_doc/v1" + "github.com/XingfenD/yoresee_doc/collab-go/proxy" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type WSHandler struct { + authenticator *auth.Authenticator + proxy *proxy.Proxy + documentService yoreseedocpb.DocumentServiceClient + internalRPCKey string +} + +func NewWSHandler(authenticator *auth.Authenticator, proxy *proxy.Proxy, documentService yoreseedocpb.DocumentServiceClient, internalRPCKey string) *WSHandler { + return &WSHandler{ + authenticator: authenticator, + proxy: proxy, + documentService: documentService, + internalRPCKey: internalRPCKey, + } +} + +func (h *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + docID := strings.TrimPrefix(r.URL.Path, "/ws/doc/") + if docID == "" || strings.Contains(docID, "/") { + http.Error(w, "invalid doc id", http.StatusBadRequest) + return + } + + if err := authenticateRequest(r, h.authenticator); err != nil { + log.Printf("collab-gateway unauthorized path=%s remote=%s err=%v", r.URL.Path, r.RemoteAddr, err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if code, err := checkDocumentExists(r.Context(), h.documentService, h.internalRPCKey, docID); err != nil { + log.Printf("collab-gateway doc check failed docID=%s err=%v", docID, err) + http.Error(w, err.Error(), code) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("collab-gateway upgrade failed path=%s remote=%s err=%v", r.URL.Path, r.RemoteAddr, err) + return + } + defer conn.Close() + + coreConn, err := h.proxy.DialCore(docID) + if err != nil { + log.Printf("collab-gateway dial core failed docID=%s err=%v", docID, err) + return + } + defer coreConn.Close() + + errCh := make(chan error, 2) + go h.proxy.ProxyWS(conn, coreConn, errCh) + go h.proxy.ProxyWS(coreConn, conn, errCh) + + <-errCh +} + +func authenticateRequest(r *http.Request, authenticator *auth.Authenticator) error { + token := r.URL.Query().Get("token") + return authenticator.ValidateToken(token) +} + +func checkDocumentExists(ctx context.Context, documentService yoreseedocpb.DocumentServiceClient, internalRPCKey, docID string) (int, error) { + if documentService == nil { + return 0, nil + } + + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + md := metadata.New(map[string]string{}) + if internalRPCKey != "" { + md.Set("x-internal-key", internalRPCKey) + } + ctx = metadata.NewOutgoingContext(ctx, md) + + resp, err := documentService.GetDocumentSettings(ctx, &yoreseedocpb.GetDocumentSettingsRequest{ + DocumentExternalId: docID, + }) + if err != nil { + return http.StatusServiceUnavailable, fmt.Errorf("service unavailable") + } + if resp.GetBase().GetCode() != 0 { + log.Printf("collab-gateway doc not found docID=%s code=%d msg=%s", docID, resp.GetBase().GetCode(), resp.GetBase().GetMessage()) + return http.StatusNotFound, fmt.Errorf("document not found") + } + return 0, nil +} diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..accb32c --- /dev/null +++ b/health/health.go @@ -0,0 +1,104 @@ +package health + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync/atomic" + "time" + + yoreseedocpb "github.com/XingfenD/yoresee_doc/collab-go/pkg/gen/yoresee_doc/v1" +) + +type HealthChecker struct { + systemService yoreseedocpb.SystemServiceClient + draining atomic.Bool +} + +type probeResponse struct { + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Backend string `json:"backend,omitempty"` +} + +func NewHealthChecker(systemService yoreseedocpb.SystemServiceClient) *HealthChecker { + return &HealthChecker{ + systemService: systemService, + } +} + +func (h *HealthChecker) SetDraining(v bool) { + h.draining.Store(v) +} + +func (h *HealthChecker) RegisterProbeRoutes(mux *http.ServeMux) { + mux.HandleFunc("/health", h.Health) + mux.HandleFunc("/readyz", h.Readiness) + mux.HandleFunc("/livez", h.Liveness) +} + +func (h *HealthChecker) Liveness(w http.ResponseWriter, _ *http.Request) { + writeProbeResponse(w, http.StatusOK, "ok", "", "") +} + +func (h *HealthChecker) Readiness(w http.ResponseWriter, _ *http.Request) { + if err := h.readinessErr(); err != nil { + writeProbeResponse(w, http.StatusServiceUnavailable, "not_ready", err.Error(), probeBackendStatusFromErr(err)) + return + } + writeProbeResponse(w, http.StatusOK, "ok", "", "ok") +} + +func (h *HealthChecker) Health(w http.ResponseWriter, _ *http.Request) { + detail := "" + backend := "ok" + if err := h.readinessErr(); err != nil { + detail = err.Error() + backend = probeBackendStatusFromErr(err) + } + writeProbeResponse(w, http.StatusOK, "ok", detail, backend) +} + +func (h *HealthChecker) readinessErr() error { + if h.draining.Load() { + return fmt.Errorf("server is draining") + } + return h.checkBackend() +} + +func writeProbeResponse(w http.ResponseWriter, statusCode int, status, detail, backend string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(probeResponse{ + Status: status, + Detail: detail, + Backend: backend, + }) +} + +func probeBackendStatusFromErr(err error) string { + if err == nil { + return "ok" + } + if err.Error() == "server is draining" { + return "unknown" + } + return "unhealthy" +} + +func (h *HealthChecker) checkBackend() error { + if h.systemService == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if _, err := h.systemService.Health(ctx, &yoreseedocpb.HealthRequest{}); err != nil { + log.Printf("backend health check failed: %v", err) + return err + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b10baeb --- /dev/null +++ b/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/XingfenD/yoresee_doc/collab-go/auth" + "github.com/XingfenD/yoresee_doc/collab-go/config" + "github.com/XingfenD/yoresee_doc/collab-go/handler" + "github.com/XingfenD/yoresee_doc/collab-go/health" + yoreseedocpb "github.com/XingfenD/yoresee_doc/collab-go/pkg/gen/yoresee_doc/v1" + "github.com/XingfenD/yoresee_doc/collab-go/proxy" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + var systemService yoreseedocpb.SystemServiceClient + var documentService yoreseedocpb.DocumentServiceClient + + grpcClient, err := grpc.NewClient(cfg.BackendGRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Printf("Warning: failed to initialize gRPC client: %v. Health check will show backend as unknown.", err) + } else { + systemService = yoreseedocpb.NewSystemServiceClient(grpcClient) + documentService = yoreseedocpb.NewDocumentServiceClient(grpcClient) + defer grpcClient.Close() + } + + authenticator := auth.NewAuthenticator(cfg.JWTSecret) + proxyHandler := proxy.NewProxy(cfg.CollabCoreURL) + healthChecker := health.NewHealthChecker(systemService) + wsHandler := handler.NewWSHandler(authenticator, proxyHandler, documentService, cfg.InternalRPCKey) + + mux := http.NewServeMux() + healthChecker.RegisterProbeRoutes(mux) + mux.Handle("/ws/doc/", wsHandler) + + server := &http.Server{ + Addr: cfg.Addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("collab-gateway listening on %s", cfg.Addr) + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("collab-gateway exited unexpectedly: %v", err) + } + }() + + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer stop() + <-signalCtx.Done() + + log.Printf("shutdown signal received, start draining") + healthChecker.SetDraining(true) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("collab-gateway graceful shutdown failed: %v", err) + } +} diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..073e892 --- /dev/null +++ b/proxy/proxy.go @@ -0,0 +1,57 @@ +package proxy + +import ( + "io" + "net/url" + + "github.com/gorilla/websocket" +) + +type Proxy struct { + coreURL string +} + +func NewProxy(coreURL string) *Proxy { + return &Proxy{ + coreURL: coreURL, + } +} + +func (p *Proxy) DialCore(docID string) (*websocket.Conn, error) { + u, err := url.Parse(p.coreURL) + if err != nil { + return nil, err + } + u.Path = "/doc-" + docID + u.RawQuery = "" + + coreConn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + return nil, err + } + + return coreConn, nil +} + +func (p *Proxy) ProxyWS(src, dst *websocket.Conn, errCh chan<- error) { + for { + msgType, r, err := src.NextReader() + if err != nil { + errCh <- err + return + } + w, err := dst.NextWriter(msgType) + if err != nil { + errCh <- err + return + } + if _, err := io.Copy(w, r); err != nil { + errCh <- err + return + } + if err := w.Close(); err != nil { + errCh <- err + return + } + } +}