Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at aeb6d806234e3bfbb5d2afdf7d753b0e5c8abdce 1043 lines 29 kB view raw
1package handler 2 3import ( 4 "encoding/json" 5 "fmt" 6 "html/template" 7 "log" 8 "net/http" 9 "net/url" 10 "regexp" 11 "strings" 12 "time" 13 14 "github.com/golang-jwt/jwt/v5" 15 "github.com/gorilla/websocket" 16 17 "github.com/limeleaf/diffdown/internal/atproto/xrpc" 18 "github.com/limeleaf/diffdown/internal/auth" 19 "github.com/limeleaf/diffdown/internal/collaboration" 20 "github.com/limeleaf/diffdown/internal/db" 21 "github.com/limeleaf/diffdown/internal/model" 22 "github.com/limeleaf/diffdown/internal/render" 23) 24 25const collectionDocument = "com.diffdown.document" 26const collectionComment = "com.diffdown.comment" 27 28type Handler struct { 29 DB *db.DB 30 Tmpls map[string]*template.Template 31 BaseURL string 32 CollaborationHub *collaboration.Hub 33} 34 35func New(database *db.DB, tmpls map[string]*template.Template, baseURL string, collabHub *collaboration.Hub) *Handler { 36 return &Handler{DB: database, Tmpls: tmpls, BaseURL: baseURL, CollaborationHub: collabHub} 37} 38 39// --- Template helpers --- 40 41type PageData struct { 42 Title string 43 User *model.User 44 Content interface{} 45 Error string 46 Description string 47 OGImage string 48} 49 50// DocumentEditData is passed to document_edit.html. 51type DocumentEditData struct { 52 *model.Document 53 // AccessToken is the ATProto access token for WebSocket auth. 54 // Empty string if user has no ATProto session. 55 AccessToken string 56 // IsOwner is true when the current user owns (created) the document. 57 IsOwner bool 58 // IsCollaborator is true when the current user is in the collaborators list. 59 IsCollaborator bool 60 // OwnerDID is the document owner's ATProto DID. Empty when IsOwner is true. 61 // Used by collaborators to route save requests to the owner's PDS. 62 OwnerDID string 63} 64 65func (h *Handler) currentUser(r *http.Request) *model.User { 66 uid := auth.UserIDFromContext(r.Context()) 67 if uid == "" { 68 return nil 69 } 70 u, err := h.DB.GetUserByID(uid) 71 if err != nil { 72 return nil 73 } 74 return u 75} 76 77func (h *Handler) render(w http.ResponseWriter, name string, data PageData) { 78 tmpl, ok := h.Tmpls[name] 79 if !ok { 80 log.Printf("template not found: %s", name) 81 http.Error(w, "Internal error", 500) 82 return 83 } 84 w.Header().Set("Content-Type", "text/html; charset=utf-8") 85 if err := tmpl.ExecuteTemplate(w, name, data); err != nil { 86 log.Printf("template error: %v", err) 87 http.Error(w, "Internal error", 500) 88 } 89} 90 91func (h *Handler) jsonResponse(w http.ResponseWriter, data interface{}, statusCode int) { 92 w.Header().Set("Content-Type", "application/json") 93 w.WriteHeader(statusCode) 94 json.NewEncoder(w).Encode(data) 95} 96 97func (h *Handler) xrpcClient(userID string) (*xrpc.Client, error) { 98 return xrpc.NewClient(h.DB, userID) 99} 100 101// --- Auth handlers --- 102 103func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) { 104 h.render(w, "login.html", PageData{Title: "Log In"}) 105} 106 107func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) { 108 email := strings.TrimSpace(r.FormValue("email")) 109 password := r.FormValue("password") 110 111 user, err := h.DB.GetUserByEmail(email) 112 if err != nil || user.PasswordHash == nil || !auth.CheckPassword(*user.PasswordHash, password) { 113 h.render(w, "login.html", PageData{Title: "Log In", Error: "Invalid email or password"}) 114 return 115 } 116 117 auth.SetUserID(w, r, user.ID) 118 http.Redirect(w, r, "/", http.StatusSeeOther) 119} 120 121func (h *Handler) RegisterPage(w http.ResponseWriter, r *http.Request) { 122 h.render(w, "register.html", PageData{Title: "Sign Up"}) 123} 124 125func (h *Handler) RegisterSubmit(w http.ResponseWriter, r *http.Request) { 126 name := strings.TrimSpace(r.FormValue("name")) 127 email := strings.TrimSpace(r.FormValue("email")) 128 password := r.FormValue("password") 129 130 if name == "" || email == "" || len(password) < 8 { 131 h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Name, email required. Password min 8 chars."}) 132 return 133 } 134 135 hash, err := auth.HashPassword(password) 136 if err != nil { 137 h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Server error"}) 138 return 139 } 140 141 user := &model.User{ 142 Name: name, 143 Email: email, 144 PasswordHash: &hash, 145 } 146 if err := h.DB.CreateUser(user); err != nil { 147 h.render(w, "register.html", PageData{Title: "Sign Up", Error: "Email already registered"}) 148 return 149 } 150 151 auth.SetUserID(w, r, user.ID) 152 http.Redirect(w, r, "/", http.StatusSeeOther) 153} 154 155func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { 156 auth.ClearSession(w, r) 157 http.Redirect(w, r, "/", http.StatusSeeOther) 158} 159 160// --- Dashboard --- 161 162func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 163 user := h.currentUser(r) 164 if user == nil { 165 h.render(w, "landing.html", PageData{Title: "Diffdown"}) 166 return 167 } 168 169 client, err := h.xrpcClient(user.ID) 170 if err != nil { 171 log.Printf("Dashboard: xrpc client: %v", err) 172 h.render(w, "documents.html", PageData{ 173 Title: "Documents", 174 User: user, 175 Content: []*model.Document{}, 176 }) 177 return 178 } 179 180 records, _, err := client.ListRecords(client.DID(), collectionDocument, 100, "") 181 if err != nil { 182 log.Printf("Dashboard: list records: %v", err) 183 h.render(w, "documents.html", PageData{ 184 Title: "Documents", 185 User: user, 186 Content: []*model.Document{}, 187 }) 188 return 189 } 190 191 var docs []*model.Document 192 for _, rec := range records { 193 doc := &model.Document{} 194 if err := json.Unmarshal(rec.Value, doc); err != nil { 195 continue 196 } 197 doc.URI = rec.URI 198 doc.CID = rec.CID 199 doc.RKey = model.RKeyFromURI(rec.URI) 200 docs = append(docs, doc) 201 } 202 203 h.render(w, "documents.html", PageData{ 204 Title: "Documents", 205 User: user, 206 Content: docs, 207 }) 208} 209 210// --- Document handlers --- 211 212func (h *Handler) NewDocumentPage(w http.ResponseWriter, r *http.Request) { 213 user := h.currentUser(r) 214 if user == nil { 215 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 216 return 217 } 218 h.render(w, "new_document.html", PageData{Title: "New Document", User: user}) 219} 220 221// stripMarkdown removes basic markdown syntax to produce plain text for textContent. 222var mdSyntaxRe = regexp.MustCompile(`(?m)^#{1,6}\s+|[*_~` + "`" + `\[\]()>]`) 223 224func stripMarkdown(md string) string { 225 return strings.TrimSpace(mdSyntaxRe.ReplaceAllString(md, "")) 226} 227 228func (h *Handler) NewDocumentSubmit(w http.ResponseWriter, r *http.Request) { 229 user := h.currentUser(r) 230 if user == nil { 231 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 232 return 233 } 234 235 title := strings.TrimSpace(r.FormValue("title")) 236 if title == "" { 237 h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Title is required"}) 238 return 239 } 240 241 client, err := h.xrpcClient(user.ID) 242 if err != nil { 243 log.Printf("NewDocumentSubmit: xrpc client: %v", err) 244 h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Could not connect to your PDS"}) 245 return 246 } 247 248 now := time.Now().UTC().Format(time.RFC3339) 249 initialMD := fmt.Sprintf("# %s\n", title) 250 doc := map[string]interface{}{ 251 "$type": "com.diffdown.document", 252 "title": title, 253 "content": map[string]interface{}{ 254 "$type": "at.markpub.markdown", 255 "flavor": "gfm", 256 "text": map[string]interface{}{ 257 "rawMarkdown": initialMD, 258 }, 259 }, 260 "textContent": stripMarkdown(initialMD), 261 "createdAt": now, 262 "updatedAt": now, 263 } 264 265 uri, _, err := client.CreateRecord(collectionDocument, doc) 266 if err != nil { 267 log.Printf("NewDocumentSubmit: create record: %v", err) 268 h.render(w, "new_document.html", PageData{Title: "New Document", User: user, Error: "Could not create document"}) 269 return 270 } 271 272 rkey := model.RKeyFromURI(uri) 273 http.Redirect(w, r, fmt.Sprintf("/docs/%s/edit", rkey), http.StatusSeeOther) 274} 275 276// documentView is the shared implementation for viewing a document given an ownerDID and rkey. 277// isOwner should be true when the current user owns the document; it suppresses the ownerDID 278// in the template so the edit button links to /docs/{rkey}/edit rather than the collaborator URL. 279func (h *Handler) documentView(w http.ResponseWriter, r *http.Request, ownerUserID, ownerDID, rkey string, isOwner bool) { 280 client, err := h.xrpcClient(ownerUserID) 281 if err != nil { 282 http.Error(w, "Could not connect to PDS", 500) 283 return 284 } 285 286 value, _, err := client.GetRecord(ownerDID, collectionDocument, rkey) 287 if err != nil { 288 http.NotFound(w, r) 289 return 290 } 291 292 doc := &model.Document{} 293 if err := json.Unmarshal(value, doc); err != nil { 294 http.Error(w, "Invalid document", 500) 295 return 296 } 297 doc.RKey = rkey 298 299 var rendered string 300 if doc.Content != nil { 301 rendered, _ = render.Markdown([]byte(doc.Content.Text.RawMarkdown)) 302 } 303 304 user := h.currentUser(r) 305 type DocumentViewData struct { 306 Doc *model.Document 307 Rendered template.HTML 308 OwnerDID string // non-empty when viewing a collaborator's document 309 } 310 // Only set OwnerDID when the viewer is not the owner; the template uses 311 // a non-empty OwnerDID to generate the collaborator edit URL. 312 templateOwnerDID := ownerDID 313 if isOwner { 314 templateOwnerDID = "" 315 } 316 h.render(w, "document_view.html", PageData{ 317 Title: doc.Title, 318 User: user, 319 Content: DocumentViewData{Doc: doc, Rendered: template.HTML(rendered), OwnerDID: templateOwnerDID}, 320 }) 321} 322 323// DocumentView renders a document as HTML (owner viewing their own doc). 324func (h *Handler) DocumentView(w http.ResponseWriter, r *http.Request) { 325 user := h.currentUser(r) 326 if user == nil { 327 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 328 return 329 } 330 331 rkey := r.PathValue("rkey") 332 client, err := h.xrpcClient(user.ID) 333 if err != nil { 334 http.Error(w, "Could not connect to PDS", 500) 335 return 336 } 337 h.documentView(w, r, user.ID, client.DID(), rkey, true) 338} 339 340// CollaboratorDocumentView renders a document owned by another user (collaborator access). 341func (h *Handler) CollaboratorDocumentView(w http.ResponseWriter, r *http.Request) { 342 user := h.currentUser(r) 343 if user == nil { 344 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 345 return 346 } 347 348 ownerDID := r.PathValue("did") 349 rkey := r.PathValue("rkey") 350 351 ownerUser, err := h.DB.GetUserByDID(ownerDID) 352 if err != nil { 353 http.NotFound(w, r) 354 return 355 } 356 357 h.documentView(w, r, ownerUser.ID, ownerDID, rkey, false) 358} 359 360// documentEdit is the shared implementation for the edit page. 361// ownerUserID/ownerDID identify whose PDS holds the document; isOwner is true for the creator. 362func (h *Handler) documentEdit(w http.ResponseWriter, r *http.Request, user *model.User, ownerUserID, ownerDID, rkey string, isOwner bool) { 363 ownerClient, err := h.xrpcClient(ownerUserID) 364 if err != nil { 365 http.Error(w, "Could not connect to PDS", 500) 366 return 367 } 368 369 value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rkey) 370 if err != nil { 371 http.NotFound(w, r) 372 return 373 } 374 375 doc := &model.Document{} 376 if err := json.Unmarshal(value, doc); err != nil { 377 http.Error(w, "Invalid document", 500) 378 return 379 } 380 doc.RKey = rkey 381 382 editData := &DocumentEditData{Document: doc, IsOwner: isOwner} 383 if !isOwner { 384 editData.OwnerDID = ownerDID 385 } 386 if session, err := h.DB.GetATProtoSession(user.ID); err == nil && session != nil { 387 editData.AccessToken = session.AccessToken 388 userDID := session.DID 389 for _, did := range doc.Collaborators { 390 if did == userDID { 391 editData.IsCollaborator = true 392 break 393 } 394 } 395 } 396 397 h.render(w, "document_edit.html", PageData{ 398 Title: "Edit " + doc.Title, 399 User: user, 400 Content: editData, 401 }) 402} 403 404// DocumentEdit renders the editor for a document (owner). 405func (h *Handler) DocumentEdit(w http.ResponseWriter, r *http.Request) { 406 user := h.currentUser(r) 407 if user == nil { 408 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 409 return 410 } 411 412 rkey := r.PathValue("rkey") 413 client, err := h.xrpcClient(user.ID) 414 if err != nil { 415 http.Error(w, "Could not connect to PDS", 500) 416 return 417 } 418 419 h.documentEdit(w, r, user, user.ID, client.DID(), rkey, true) 420} 421 422// CollaboratorDocumentEdit renders the editor for a document owned by another user. 423func (h *Handler) CollaboratorDocumentEdit(w http.ResponseWriter, r *http.Request) { 424 user := h.currentUser(r) 425 if user == nil { 426 http.Redirect(w, r, "/auth/atproto", http.StatusSeeOther) 427 return 428 } 429 430 ownerDID := r.PathValue("did") 431 rkey := r.PathValue("rkey") 432 433 ownerUser, err := h.DB.GetUserByDID(ownerDID) 434 if err != nil { 435 http.NotFound(w, r) 436 return 437 } 438 439 h.documentEdit(w, r, user, ownerUser.ID, ownerDID, rkey, false) 440} 441 442// APIDocumentSave saves a document to the PDS. 443func (h *Handler) APIDocumentSave(w http.ResponseWriter, r *http.Request) { 444 user := h.currentUser(r) 445 if user == nil { 446 http.Error(w, "Unauthorized", 401) 447 return 448 } 449 450 rkey := r.PathValue("rkey") 451 var req struct { 452 Content string `json:"content"` 453 Title string `json:"title"` 454 OwnerDID string `json:"ownerDID"` // non-empty when saving on behalf of another user (collaborator) 455 } 456 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 457 http.Error(w, "Bad request", 400) 458 return 459 } 460 461 // For collaborators, save to the document owner's PDS, not the collaborator's. 462 var client *xrpc.Client 463 var repoDID string 464 if req.OwnerDID != "" { 465 ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 466 if err != nil { 467 log.Printf("APIDocumentSave: get owner by DID %s: %v", req.OwnerDID, err) 468 http.Error(w, "Document owner not found", 404) 469 return 470 } 471 client, err = h.xrpcClient(ownerUser.ID) 472 if err != nil { 473 http.Error(w, "Could not connect to owner PDS", 500) 474 return 475 } 476 repoDID = req.OwnerDID 477 } else { 478 var err error 479 client, err = h.xrpcClient(user.ID) 480 if err != nil { 481 http.Error(w, "Could not connect to PDS", 500) 482 return 483 } 484 repoDID = client.DID() 485 } 486 487 // Fetch existing record to preserve fields 488 value, _, err := client.GetRecord(repoDID, collectionDocument, rkey) 489 if err != nil { 490 http.Error(w, "Document not found", 404) 491 return 492 } 493 494 var existing map[string]interface{} 495 json.Unmarshal(value, &existing) 496 497 // Update content 498 title := req.Title 499 if title == "" { 500 if t, ok := existing["title"].(string); ok { 501 title = t 502 } 503 } 504 505 now := time.Now().UTC().Format(time.RFC3339) 506 existing["title"] = title 507 existing["content"] = map[string]interface{}{ 508 "$type": "at.markpub.markdown", 509 "flavor": "gfm", 510 "text": map[string]interface{}{ 511 "rawMarkdown": req.Content, 512 }, 513 } 514 existing["textContent"] = stripMarkdown(req.Content) 515 existing["updatedAt"] = now 516 517 _, _, err = client.PutRecord(collectionDocument, rkey, existing) 518 if err != nil { 519 log.Printf("APIDocumentSave: put record: %v", err) 520 http.Error(w, "Save failed", 500) 521 return 522 } 523 524 h.jsonResponse(w, map[string]string{"status": "ok"}, http.StatusOK) 525} 526 527// APIDocumentAutoSave is the same as save, called on debounce from editor. 528func (h *Handler) APIDocumentAutoSave(w http.ResponseWriter, r *http.Request) { 529 h.APIDocumentSave(w, r) 530} 531 532// APIDocumentDelete deletes a document from the PDS. 533func (h *Handler) APIDocumentDelete(w http.ResponseWriter, r *http.Request) { 534 user := h.currentUser(r) 535 if user == nil { 536 http.Error(w, "Unauthorized", 401) 537 return 538 } 539 540 rkey := r.PathValue("rkey") 541 client, err := h.xrpcClient(user.ID) 542 if err != nil { 543 http.Error(w, "Could not connect to PDS", 500) 544 return 545 } 546 547 if err := client.DeleteRecord(collectionDocument, rkey); err != nil { 548 log.Printf("APIDocumentDelete: %v", err) 549 http.Error(w, "Delete failed", 500) 550 return 551 } 552 553 h.jsonResponse(w, map[string]string{"status": "ok"}, http.StatusOK) 554} 555 556// DocumentInvite creates an invite link for a document. 557func (h *Handler) DocumentInvite(w http.ResponseWriter, r *http.Request) { 558 user := h.currentUser(r) 559 if user == nil { 560 http.Redirect(w, r, "/auth/login", http.StatusSeeOther) 561 return 562 } 563 564 rkey := r.PathValue("rkey") 565 if rkey == "" { 566 http.Error(w, "Invalid document", http.StatusBadRequest) 567 return 568 } 569 570 client, err := h.xrpcClient(user.ID) 571 if err != nil { 572 log.Printf("DocumentInvite: xrpc client: %v", err) 573 h.render(w, "document_edit.html", PageData{Error: "Not authenticated with ATProto"}) 574 return 575 } 576 577 value, _, err := client.GetRecord(client.DID(), collectionDocument, rkey) 578 if err != nil { 579 http.Error(w, "Document not found", http.StatusNotFound) 580 return 581 } 582 583 doc := &model.Document{} 584 if err := json.Unmarshal(value, doc); err != nil { 585 http.Error(w, "Invalid document", http.StatusInternalServerError) 586 return 587 } 588 doc.RKey = rkey 589 590 // The document was fetched via client.DID(), so the current user is always the owner. 591 if len(doc.Collaborators) >= 5 { 592 http.Error(w, "Maximum collaborators reached", http.StatusBadRequest) 593 return 594 } 595 596 invite, err := collaboration.CreateInvite(h.DB, rkey, client.DID()) 597 if err != nil { 598 log.Printf("DocumentInvite: create invite: %v", err) 599 http.Error(w, "Failed to create invite", http.StatusInternalServerError) 600 return 601 } 602 603 inviteLink := fmt.Sprintf("%s/docs/%s/accept?invite=%s", h.BaseURL, rkey, invite.Token) 604 h.jsonResponse(w, map[string]string{"inviteLink": inviteLink}, http.StatusOK) 605} 606 607// AcceptInvite handles an invite acceptance. 608func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { 609 user := h.currentUser(r) 610 if user == nil { 611 // Preserve invite token through the login redirect. 612 http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 613 return 614 } 615 616 rKey := r.PathValue("rkey") 617 inviteToken := r.URL.Query().Get("invite") 618 if inviteToken == "" { 619 http.Error(w, "Invalid invite", http.StatusBadRequest) 620 return 621 } 622 623 invite, err := collaboration.ValidateInvite(h.DB, inviteToken, rKey) 624 if err != nil { 625 log.Printf("AcceptInvite: validate invite rkey=%s: %v", rKey, err) 626 http.Error(w, "Invite not found, already used, or expired.", http.StatusBadRequest) 627 return 628 } 629 630 // The collaborator's session — needed to get their DID. 631 collabSession, err := h.DB.GetATProtoSession(user.ID) 632 if err != nil || collabSession == nil { 633 http.Redirect(w, r, "/auth/atproto?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther) 634 return 635 } 636 637 // Fetch and update the document from the OWNER's PDS, not the collaborator's. 638 // The invite records the owner's DID in CreatedBy. 639 ownerUser, err := h.DB.GetUserByDID(invite.CreatedBy) 640 if err != nil { 641 log.Printf("AcceptInvite: get owner by DID %s: %v", invite.CreatedBy, err) 642 http.Error(w, "Document owner not found", http.StatusInternalServerError) 643 return 644 } 645 646 ownerClient, err := h.xrpcClient(ownerUser.ID) 647 if err != nil { 648 log.Printf("AcceptInvite: owner xrpc client: %v", err) 649 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 650 return 651 } 652 653 doc, err := ownerClient.GetDocument(rKey) 654 if err != nil { 655 log.Printf("AcceptInvite: get document: %v", err) 656 http.Error(w, "Document not found", http.StatusNotFound) 657 return 658 } 659 660 // Already a collaborator — redirect to the owner-scoped URL. 661 for _, c := range doc.Collaborators { 662 if c == collabSession.DID { 663 http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 664 return 665 } 666 } 667 668 // Add collaborator DID and PUT back to owner's PDS. 669 doc.Collaborators = append(doc.Collaborators, collabSession.DID) 670 if _, _, err = ownerClient.PutDocument(rKey, doc); err != nil { 671 log.Printf("AcceptInvite: put document: %v", err) 672 http.Error(w, "Failed to add collaborator", http.StatusInternalServerError) 673 return 674 } 675 676 h.DB.DeleteInvite(invite.Token) 677 678 // Redirect to owner-scoped document URL so the view handler knows whose PDS to query. 679 http.Redirect(w, r, "/docs/"+invite.CreatedBy+"/"+rKey, http.StatusSeeOther) 680} 681 682// --- API: Comments --- 683 684func (h *Handler) CommentCreate(w http.ResponseWriter, r *http.Request) { 685 user := h.currentUser(r) 686 if user == nil { 687 http.Error(w, "Unauthorized", http.StatusUnauthorized) 688 return 689 } 690 691 rKey := r.PathValue("rkey") 692 if rKey == "" { 693 http.Error(w, "Invalid document", http.StatusBadRequest) 694 return 695 } 696 697 var req struct { 698 ParagraphID string `json:"paragraphId"` 699 Text string `json:"text"` 700 } 701 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 702 http.Error(w, "Invalid request", http.StatusBadRequest) 703 return 704 } 705 706 if req.Text == "" { 707 http.Error(w, "Comment text required", http.StatusBadRequest) 708 return 709 } 710 711 session, err := h.DB.GetATProtoSession(user.ID) 712 if err != nil || session == nil { 713 http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 714 return 715 } 716 717 client, err := h.xrpcClient(user.ID) 718 if err != nil { 719 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 720 return 721 } 722 723 comment := &model.Comment{ 724 DocumentURI: fmt.Sprintf("at://%s/com.diffdown.document/%s", session.DID, rKey), 725 ParagraphID: req.ParagraphID, 726 Text: req.Text, 727 Author: session.DID, 728 } 729 730 uri, err := client.CreateComment(comment) 731 if err != nil { 732 log.Printf("CommentCreate: %v", err) 733 http.Error(w, "Failed to create comment", http.StatusInternalServerError) 734 return 735 } 736 737 h.jsonResponse(w, map[string]string{"uri": uri}, http.StatusCreated) 738} 739 740func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 741 rKey := r.PathValue("rkey") 742 if rKey == "" { 743 http.Error(w, "Invalid document", http.StatusBadRequest) 744 return 745 } 746 747 user := h.currentUser(r) 748 if user == nil { 749 http.Error(w, "Unauthorized", http.StatusUnauthorized) 750 return 751 } 752 753 session, err := h.DB.GetATProtoSession(user.ID) 754 if err != nil || session == nil { 755 http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 756 return 757 } 758 759 client, err := h.xrpcClient(user.ID) 760 if err != nil { 761 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 762 return 763 } 764 765 comments, err := client.ListComments(rKey) 766 if err != nil { 767 log.Printf("CommentList: %v", err) 768 http.Error(w, "Failed to list comments", http.StatusInternalServerError) 769 return 770 } 771 772 h.jsonResponse(w, comments, http.StatusOK) 773} 774 775// --- API: Render markdown --- 776 777func (h *Handler) APIRender(w http.ResponseWriter, r *http.Request) { 778 var req struct { 779 Content string `json:"content"` 780 } 781 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 782 http.Error(w, "Bad request", 400) 783 return 784 } 785 786 rendered, err := render.Markdown([]byte(req.Content)) 787 if err != nil { 788 http.Error(w, "Render error", 500) 789 return 790 } 791 792 h.jsonResponse(w, map[string]string{"html": rendered}, http.StatusOK) 793} 794 795var upgrader = websocket.Upgrader{ 796 CheckOrigin: func(r *http.Request) bool { return true }, 797} 798 799func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 800 rKey := r.PathValue("rkey") 801 if rKey == "" { 802 http.Error(w, "Invalid document", http.StatusBadRequest) 803 return 804 } 805 806 accessToken := r.URL.Query().Get("access_token") 807 dpopProof := r.URL.Query().Get("dpop_proof") 808 if accessToken == "" || dpopProof == "" { 809 http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 810 return 811 } 812 813 did, name, err := h.validateWSToken(accessToken, dpopProof) 814 if err != nil { 815 http.Error(w, "Invalid tokens", http.StatusUnauthorized) 816 return 817 } 818 819 user, err := h.DB.GetUserByDID(did) 820 if err != nil { 821 http.Error(w, "No user found", http.StatusUnauthorized) 822 return 823 } 824 825 session, err := h.DB.GetATProtoSession(user.ID) 826 if err != nil || session == nil { 827 http.Error(w, "No ATProto session", http.StatusUnauthorized) 828 return 829 } 830 831 // If owner_did is provided, fetch the document from the owner's PDS 832 // (used by collaborators whose copy lives on a different PDS). 833 ownerDID := r.URL.Query().Get("owner_did") 834 var docClient *xrpc.Client 835 var docRepoDID string 836 if ownerDID != "" { 837 ownerUser, err := h.DB.GetUserByDID(ownerDID) 838 if err != nil { 839 log.Printf("CollaboratorWebSocket: get owner by DID %s: %v", ownerDID, err) 840 http.Error(w, "Document owner not found", http.StatusForbidden) 841 return 842 } 843 docClient, err = h.xrpcClient(ownerUser.ID) 844 if err != nil { 845 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 846 return 847 } 848 docRepoDID = ownerDID 849 } else { 850 docClient, err = h.xrpcClient(session.UserID) 851 if err != nil { 852 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 853 return 854 } 855 docRepoDID = did 856 } 857 858 value, _, err := docClient.GetRecord(docRepoDID, collectionDocument, rKey) 859 if err != nil { 860 http.Error(w, "Document not found", http.StatusNotFound) 861 return 862 } 863 doc := &model.Document{} 864 if err := json.Unmarshal(value, doc); err != nil { 865 http.Error(w, "Invalid document", http.StatusInternalServerError) 866 return 867 } 868 869 // Owner always has access; collaborators must be in the collaborators list. 870 isCollaborator := did == docRepoDID 871 for _, c := range doc.Collaborators { 872 if c == did { 873 isCollaborator = true 874 break 875 } 876 } 877 if !isCollaborator { 878 http.Error(w, "Not a collaborator", http.StatusForbidden) 879 return 880 } 881 882 color := colorFromDID(did) 883 884 conn, err := upgrader.Upgrade(w, r, nil) 885 if err != nil { 886 log.Printf("WebSocket upgrade failed: %v", err) 887 return 888 } 889 890 room := h.CollaborationHub.GetOrCreateRoom(rKey) 891 wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, color, rKey) 892 room.RegisterClient(wsClient) 893 894 go wsClient.WritePump() 895 wsClient.ReadPump() 896} 897 898func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 899 claims := &jwt.MapClaims{} 900 parser := jwt.Parser{} 901 _, _, err := parser.ParseUnverified(accessToken, claims) 902 if err != nil { 903 return "", "", fmt.Errorf("parse token: %w", err) 904 } 905 906 did, ok := (*claims)["sub"].(string) 907 if !ok { 908 return "", "", fmt.Errorf("no sub in token") 909 } 910 911 user, err := h.DB.GetUserByDID(did) 912 if err != nil { 913 return "", "", fmt.Errorf("user not found: %w", err) 914 } 915 916 session, err := h.DB.GetATProtoSession(user.ID) 917 if err != nil { 918 return "", "", fmt.Errorf("session not found: %w", err) 919 } 920 921 if time.Now().After(session.ExpiresAt) { 922 return "", "", fmt.Errorf("session expired") 923 } 924 925 name, _ := (*claims)["name"].(string) 926 return did, name, nil 927} 928 929func colorFromDID(did string) string { 930 colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 931 hash := 0 932 for _, c := range did { 933 hash += int(c) 934 } 935 return colors[hash%len(colors)] 936} 937 938// SubmitSteps receives ProseMirror steps from a collaborator, appends them 939// to the step log, and broadcasts confirmed steps to the room. 940// 941// POST /api/docs/{rkey}/steps 942// Body: {"clientVersion": N, "steps": ["...json..."], "clientID": "did:..."} 943// Response 200: {"version": N} 944// Response 409: {"version": N, "steps": ["...json..."]} — client must rebase 945func (h *Handler) SubmitSteps(w http.ResponseWriter, r *http.Request) { 946 user := h.currentUser(r) 947 if user == nil { 948 http.Error(w, "Unauthorized", http.StatusUnauthorized) 949 return 950 } 951 rkey := r.PathValue("rkey") 952 953 var body struct { 954 ClientVersion int `json:"clientVersion"` 955 Steps []string `json:"steps"` 956 ClientID string `json:"clientID"` 957 } 958 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 959 http.Error(w, "Bad request", http.StatusBadRequest) 960 return 961 } 962 if len(body.Steps) == 0 { 963 http.Error(w, "No steps", http.StatusBadRequest) 964 return 965 } 966 967 newVersion, err := h.DB.AppendSteps(rkey, body.ClientVersion, body.Steps, body.ClientID) 968 if err != nil { 969 // Version conflict — return steps the client missed. 970 missed, dbErr := h.DB.GetStepsSince(rkey, body.ClientVersion) 971 if dbErr != nil { 972 log.Printf("SubmitSteps: GetStepsSince: %v", dbErr) 973 http.Error(w, "Internal error", http.StatusInternalServerError) 974 return 975 } 976 currentVersion, _ := h.DB.GetDocVersion(rkey) 977 stepJSONs := make([]string, len(missed)) 978 for i, s := range missed { 979 stepJSONs[i] = s.JSON 980 } 981 w.Header().Set("Content-Type", "application/json") 982 w.WriteHeader(http.StatusConflict) 983 json.NewEncoder(w).Encode(map[string]interface{}{ 984 "version": currentVersion, 985 "steps": stepJSONs, 986 }) 987 return 988 } 989 990 // Broadcast to other room members via WebSocket. 991 if room := h.CollaborationHub.GetRoom(rkey); room != nil { 992 type stepsMsg struct { 993 Type string `json:"type"` 994 Steps []string `json:"steps"` 995 Version int `json:"version"` 996 ClientID string `json:"clientID"` 997 } 998 data, _ := json.Marshal(stepsMsg{ 999 Type: "steps", 1000 Steps: body.Steps, 1001 Version: newVersion, 1002 ClientID: body.ClientID, 1003 }) 1004 room.Broadcast(data) 1005 } 1006 1007 h.jsonResponse(w, map[string]int{"version": newVersion}, http.StatusOK) 1008} 1009 1010// GetSteps returns all steps since the given version. 1011// 1012// GET /api/docs/{rkey}/steps?since={v} 1013// Response 200: {"version": N, "steps": ["...json..."]} 1014func (h *Handler) GetSteps(w http.ResponseWriter, r *http.Request) { 1015 user := h.currentUser(r) 1016 if user == nil { 1017 http.Error(w, "Unauthorized", http.StatusUnauthorized) 1018 return 1019 } 1020 rkey := r.PathValue("rkey") 1021 1022 sinceStr := r.URL.Query().Get("since") 1023 var since int 1024 if sinceStr != "" { 1025 fmt.Sscanf(sinceStr, "%d", &since) 1026 } 1027 1028 rows, err := h.DB.GetStepsSince(rkey, since) 1029 if err != nil { 1030 log.Printf("GetSteps: %v", err) 1031 http.Error(w, "Internal error", http.StatusInternalServerError) 1032 return 1033 } 1034 version, _ := h.DB.GetDocVersion(rkey) 1035 stepJSONs := make([]string, len(rows)) 1036 for i, s := range rows { 1037 stepJSONs[i] = s.JSON 1038 } 1039 h.jsonResponse(w, map[string]interface{}{ 1040 "version": version, 1041 "steps": stepJSONs, 1042 }, http.StatusOK) 1043}