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