migrate from github

This commit is contained in:
2026-07-15 22:50:29 +08:00
commit 32eb986565
11 changed files with 512 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-ssr
*.log
*.local
.DS_Store
.git
.vscode
.idea
pkg/gen/
+8
View File
@@ -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"]
+31
View File
@@ -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"]
+34
View File
@@ -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
}
+19
View File
@@ -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
}
+20
View File
@@ -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
)
+44
View File
@@ -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=
+109
View File
@@ -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
}
+104
View File
@@ -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
}
+76
View File
@@ -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)
}
}
+57
View File
@@ -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
}
}
}