Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: add WebSocket collaboration handler

- Add CollaboratorWebSocket handler for real-time collaboration
- Add CollaborationHub field to Handler struct
- Add validateWSToken and colorFromDID helper functions
- Add RegisterClient/UnregisterClient methods to Room
- Wire up Hub in main.go and register /ws/docs/{rkey} route

+143 -8
+7 -1
cmd/server/main.go
··· 9 9 "time" 10 10 11 11 "github.com/limeleaf/diffdown/internal/auth" 12 + "github.com/limeleaf/diffdown/internal/collaboration" 12 13 "github.com/limeleaf/diffdown/internal/db" 13 14 "github.com/limeleaf/diffdown/internal/handler" 14 15 "github.com/limeleaf/diffdown/internal/middleware" ··· 69 70 "templates/"+name, 70 71 )) 71 72 } 72 - h := handler.New(database, tmpls, baseURL) 73 + 74 + collabHub := collaboration.NewHub() 75 + h := handler.New(database, tmpls, baseURL, collabHub) 73 76 74 77 // Routes 75 78 mux := http.NewServeMux() ··· 108 111 mux.HandleFunc("GET /docs/{rkey}/accept", h.AcceptInvite) 109 112 mux.HandleFunc("POST /api/docs/{rkey}/comments", h.CommentCreate) 110 113 mux.HandleFunc("GET /api/docs/{rkey}/comments", h.CommentList) 114 + 115 + // WebSocket 116 + mux.HandleFunc("GET /ws/docs/{rkey}", h.CollaboratorWebSocket) 111 117 112 118 // Middleware stack 113 119 stack := middleware.Logger(
+1
go.mod
··· 5 5 require ( 6 6 github.com/golang-jwt/jwt/v5 v5.3.1 7 7 github.com/gorilla/sessions v1.2.2 8 + github.com/gorilla/websocket v1.5.3 8 9 github.com/mattn/go-sqlite3 v1.14.22 9 10 github.com/oklog/ulid/v2 v2.1.0 10 11 github.com/yuin/goldmark v1.7.1
+2
go.sum
··· 6 6 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 7 7 github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 8 8 github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 9 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 10 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 9 11 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 10 12 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 11 13 github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
+6 -1
internal/collaboration/client.go
··· 74 74 } 75 75 break 76 76 } 77 - _ = message 77 + var msg ClientMessage 78 + if err := json.Unmarshal(message, &msg); err != nil { 79 + log.Printf("Failed to parse message from %s: %v", c.DID, err) 80 + continue 81 + } 82 + log.Printf("Received message type=%s from %s", msg.Type, c.DID) 78 83 } 79 84 } 80 85
+16 -1
internal/collaboration/hub.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "log" 5 6 "sync" 6 7 ) 7 8 ··· 56 57 r.mu.Lock() 57 58 r.clients[client] = true 58 59 r.mu.Unlock() 60 + r.broadcastPresence() 59 61 case client := <-r.unregister: 60 62 r.mu.Lock() 61 63 if _, ok := r.clients[client]; ok { ··· 63 65 close(client.send) 64 66 } 65 67 r.mu.Unlock() 68 + r.broadcastPresence() 66 69 case message := <-r.broadcast: 67 70 r.mu.RLock() 68 71 for client := range r.clients { ··· 82 85 r.broadcast <- message 83 86 } 84 87 88 + func (r *Room) RegisterClient(client *Client) { 89 + r.register <- client 90 + } 91 + 92 + func (r *Room) UnregisterClient(client *Client) { 93 + r.unregister <- client 94 + } 95 + 85 96 func (r *Room) GetPresence() []PresenceUser { 86 97 r.mu.RLock() 87 98 defer r.mu.RUnlock() ··· 101 112 Type: "presence", 102 113 Users: r.GetPresence(), 103 114 } 104 - data, _ := json.Marshal(presence) 115 + data, err := json.Marshal(presence) 116 + if err != nil { 117 + log.Printf("broadcastPresence: marshal failed: %v", err) 118 + return 119 + } 105 120 r.Broadcast(data) 106 121 }
+111 -5
internal/handler/handler.go
··· 10 10 "strings" 11 11 "time" 12 12 13 + "github.com/golang-jwt/jwt/v5" 14 + "github.com/gorilla/websocket" 15 + 13 16 "github.com/limeleaf/diffdown/internal/atproto/xrpc" 14 17 "github.com/limeleaf/diffdown/internal/auth" 15 18 "github.com/limeleaf/diffdown/internal/collaboration" ··· 21 24 const collectionDocument = "com.diffdown.document" 22 25 23 26 type Handler struct { 24 - DB *db.DB 25 - Tmpls map[string]*template.Template 26 - BaseURL string 27 + DB *db.DB 28 + Tmpls map[string]*template.Template 29 + BaseURL string 30 + CollaborationHub *collaboration.Hub 27 31 } 28 32 29 - func New(database *db.DB, tmpls map[string]*template.Template, baseURL string) *Handler { 30 - return &Handler{DB: database, Tmpls: tmpls, BaseURL: baseURL} 33 + func New(database *db.DB, tmpls map[string]*template.Template, baseURL string, collabHub *collaboration.Hub) *Handler { 34 + return &Handler{DB: database, Tmpls: tmpls, BaseURL: baseURL, CollaborationHub: collabHub} 31 35 } 32 36 33 37 // --- Template helpers --- ··· 662 666 663 667 h.jsonResponse(w, map[string]string{"html": rendered}, http.StatusOK) 664 668 } 669 + 670 + var upgrader = websocket.Upgrader{ 671 + CheckOrigin: func(r *http.Request) bool { return true }, 672 + } 673 + 674 + func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 675 + rKey := r.PathValue("rkey") 676 + if rKey == "" { 677 + http.Error(w, "Invalid document", http.StatusBadRequest) 678 + return 679 + } 680 + 681 + accessToken := r.URL.Query().Get("access_token") 682 + dpopProof := r.URL.Query().Get("dpop_proof") 683 + if accessToken == "" || dpopProof == "" { 684 + http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 685 + return 686 + } 687 + 688 + did, name, err := h.validateWSToken(accessToken, dpopProof) 689 + if err != nil { 690 + http.Error(w, "Invalid tokens", http.StatusUnauthorized) 691 + return 692 + } 693 + 694 + user, err := h.DB.GetUserByDID(did) 695 + if err != nil { 696 + http.Error(w, "No user found", http.StatusUnauthorized) 697 + return 698 + } 699 + 700 + session, err := h.DB.GetATProtoSession(user.ID) 701 + if err != nil || session == nil { 702 + http.Error(w, "No ATProto session", http.StatusUnauthorized) 703 + return 704 + } 705 + 706 + client, err := h.xrpcClient(session.UserID) 707 + if err != nil { 708 + http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 709 + return 710 + } 711 + 712 + doc, err := client.GetDocument(rKey) 713 + if err != nil { 714 + http.Error(w, "Document not found", http.StatusNotFound) 715 + return 716 + } 717 + 718 + isCollaborator := false 719 + for _, c := range doc.Collaborators { 720 + if c == did { 721 + isCollaborator = true 722 + break 723 + } 724 + } 725 + if !isCollaborator { 726 + http.Error(w, "Not a collaborator", http.StatusForbidden) 727 + return 728 + } 729 + 730 + color := colorFromDID(did) 731 + 732 + conn, err := upgrader.Upgrade(w, r, nil) 733 + if err != nil { 734 + log.Printf("WebSocket upgrade failed: %v", err) 735 + return 736 + } 737 + 738 + room := h.CollaborationHub.GetOrCreateRoom(rKey) 739 + wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 740 + room.RegisterClient(wsClient) 741 + 742 + go wsClient.WritePump() 743 + wsClient.ReadPump() 744 + } 745 + 746 + func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 747 + claims := &jwt.MapClaims{} 748 + parser := jwt.Parser{} 749 + _, _, err := parser.ParseUnverified(accessToken, claims) 750 + if err != nil { 751 + return "", "", err 752 + } 753 + 754 + did, ok := (*claims)["sub"].(string) 755 + if !ok { 756 + return "", "", fmt.Errorf("no sub in token") 757 + } 758 + 759 + name, _ := (*claims)["name"].(string) 760 + return did, name, nil 761 + } 762 + 763 + func colorFromDID(did string) string { 764 + colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 765 + hash := 0 766 + for _, c := range did { 767 + hash += int(c) 768 + } 769 + return colors[hash%len(colors)] 770 + }