Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at 2879567a37b77ab5bedaac110f2201493b973eff 1141 lines 32 kB view raw
1package handler 2 3import ( 4 "crypto/rand" 5 "encoding/hex" 6 "encoding/json" 7 "fmt" 8 "html/template" 9 "log" 10 "net/http" 11 "net/url" 12 "regexp" 13 "strings" 14 "time" 15 16 "github.com/golang-jwt/jwt/v5" 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: "Diffdown"}) 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 ParagraphID string `json:"paragraphId"` 737 Text string `json:"text"` 738 } 739 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 740 http.Error(w, "Invalid request", http.StatusBadRequest) 741 return 742 } 743 if req.Text == "" { 744 http.Error(w, "Comment text required", http.StatusBadRequest) 745 return 746 } 747 748 session, err := h.DB.GetATProtoSession(user.ID) 749 if err != nil || session == nil { 750 http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 751 return 752 } 753 754 ownerUserID := user.ID 755 ownerDID := session.DID 756 if req.OwnerDID != "" && req.OwnerDID != session.DID { 757 ownerUser, err := h.DB.GetUserByDID(req.OwnerDID) 758 if err != nil { 759 http.Error(w, "Owner not found", http.StatusBadRequest) 760 return 761 } 762 ownerUserID = ownerUser.ID 763 ownerDID = req.OwnerDID 764 } 765 766 ownerClient, err := h.xrpcClient(ownerUserID) 767 if err != nil { 768 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 769 return 770 } 771 772 value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 773 if err != nil { 774 log.Printf("CommentCreate: GetRecord: %v", err) 775 http.Error(w, "Document not found", http.StatusNotFound) 776 return 777 } 778 var doc model.Document 779 if err := json.Unmarshal(value, &doc); err != nil { 780 http.Error(w, "Failed to parse document", http.StatusInternalServerError) 781 return 782 } 783 784 authorHandle, _ := atproto.ResolveHandleFromDID(session.DID) 785 786 paragraphID := req.ParagraphID 787 if paragraphID == "" { 788 paragraphID = "general" 789 } 790 791 comment := model.EmbeddedComment{ 792 ID: randomID(), 793 ParagraphID: paragraphID, 794 Text: req.Text, 795 Author: session.DID, 796 AuthorHandle: authorHandle, 797 CreatedAt: time.Now().UTC().Format(time.RFC3339), 798 } 799 doc.Comments = append(doc.Comments, comment) 800 801 if _, _, err := ownerClient.PutDocument(rKey, &doc); err != nil { 802 log.Printf("CommentCreate: PutDocument: %v", err) 803 http.Error(w, "Failed to save comment", http.StatusInternalServerError) 804 return 805 } 806 807 h.jsonResponse(w, comment, http.StatusCreated) 808} 809 810func (h *Handler) CommentList(w http.ResponseWriter, r *http.Request) { 811 rKey := r.PathValue("rkey") 812 if rKey == "" { 813 http.Error(w, "Invalid document", http.StatusBadRequest) 814 return 815 } 816 817 user, _ := h.currentUser(r) 818 if user == nil { 819 http.Error(w, "Unauthorized", http.StatusUnauthorized) 820 return 821 } 822 823 session, err := h.DB.GetATProtoSession(user.ID) 824 if err != nil || session == nil { 825 http.Error(w, "Not authenticated with ATProto", http.StatusUnauthorized) 826 return 827 } 828 829 ownerUserID := user.ID 830 ownerDID := session.DID 831 if qOwner := r.URL.Query().Get("ownerDID"); qOwner != "" && qOwner != session.DID { 832 ownerUser, err := h.DB.GetUserByDID(qOwner) 833 if err != nil { 834 http.Error(w, "Owner not found", http.StatusBadRequest) 835 return 836 } 837 ownerUserID = ownerUser.ID 838 ownerDID = qOwner 839 } 840 841 ownerClient, err := h.xrpcClient(ownerUserID) 842 if err != nil { 843 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 844 return 845 } 846 847 value, _, err := ownerClient.GetRecord(ownerDID, collectionDocument, rKey) 848 if err != nil { 849 log.Printf("CommentList: GetRecord: %v", err) 850 http.Error(w, "Document not found", http.StatusNotFound) 851 return 852 } 853 var doc model.Document 854 if err := json.Unmarshal(value, &doc); err != nil { 855 http.Error(w, "Failed to parse document", http.StatusInternalServerError) 856 return 857 } 858 859 comments := doc.Comments 860 if comments == nil { 861 comments = []model.EmbeddedComment{} 862 } 863 h.jsonResponse(w, comments, http.StatusOK) 864} 865 866// --- API: Render markdown --- 867 868func (h *Handler) APIRender(w http.ResponseWriter, r *http.Request) { 869 var req struct { 870 Content string `json:"content"` 871 } 872 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 873 http.Error(w, "Bad request", 400) 874 return 875 } 876 877 rendered, err := render.Markdown([]byte(req.Content)) 878 if err != nil { 879 http.Error(w, "Render error", 500) 880 return 881 } 882 883 h.jsonResponse(w, map[string]string{"html": rendered}, http.StatusOK) 884} 885 886var upgrader = websocket.Upgrader{ 887 CheckOrigin: func(r *http.Request) bool { return true }, 888} 889 890func (h *Handler) CollaboratorWebSocket(w http.ResponseWriter, r *http.Request) { 891 rKey := r.PathValue("rkey") 892 if rKey == "" { 893 http.Error(w, "Invalid document", http.StatusBadRequest) 894 return 895 } 896 897 accessToken := r.URL.Query().Get("access_token") 898 dpopProof := r.URL.Query().Get("dpop_proof") 899 if accessToken == "" || dpopProof == "" { 900 http.Error(w, "Missing auth tokens", http.StatusUnauthorized) 901 return 902 } 903 904 did, name, err := h.validateWSToken(accessToken, dpopProof) 905 if err != nil { 906 http.Error(w, "Invalid tokens", http.StatusUnauthorized) 907 return 908 } 909 910 user, err := h.DB.GetUserByDID(did) 911 if err != nil { 912 http.Error(w, "No user found", http.StatusUnauthorized) 913 return 914 } 915 916 session, err := h.DB.GetATProtoSession(user.ID) 917 if err != nil || session == nil { 918 http.Error(w, "No ATProto session", http.StatusUnauthorized) 919 return 920 } 921 922 // If owner_did is provided, fetch the document from the owner's PDS 923 // (used by collaborators whose copy lives on a different PDS). 924 ownerDID := r.URL.Query().Get("owner_did") 925 var docClient *xrpc.Client 926 var docRepoDID string 927 if ownerDID != "" { 928 ownerUser, err := h.DB.GetUserByDID(ownerDID) 929 if err != nil { 930 log.Printf("CollaboratorWebSocket: get owner by DID %s: %v", ownerDID, err) 931 http.Error(w, "Document owner not found", http.StatusForbidden) 932 return 933 } 934 docClient, err = h.xrpcClient(ownerUser.ID) 935 if err != nil { 936 http.Error(w, "Failed to connect to owner PDS", http.StatusInternalServerError) 937 return 938 } 939 docRepoDID = ownerDID 940 } else { 941 docClient, err = h.xrpcClient(session.UserID) 942 if err != nil { 943 http.Error(w, "Failed to connect to ATProto", http.StatusInternalServerError) 944 return 945 } 946 docRepoDID = did 947 } 948 949 value, _, err := docClient.GetRecord(docRepoDID, collectionDocument, rKey) 950 if err != nil { 951 http.Error(w, "Document not found", http.StatusNotFound) 952 return 953 } 954 doc := &model.Document{} 955 if err := json.Unmarshal(value, doc); err != nil { 956 http.Error(w, "Invalid document", http.StatusInternalServerError) 957 return 958 } 959 960 // Owner always has access; collaborators must be in the collaborators list. 961 isCollaborator := did == docRepoDID 962 for _, c := range doc.Collaborators { 963 if c == did { 964 isCollaborator = true 965 break 966 } 967 } 968 if !isCollaborator { 969 http.Error(w, "Not a collaborator", http.StatusForbidden) 970 return 971 } 972 973 color := colorFromDID(did) 974 975 // Fetch handle and avatar from ATProto profile; best-effort, empty on failure. 976 var avatar, handle string 977 if profile, err := atproto.ResolveProfile(did); err == nil && profile != nil { 978 avatar = profile.Avatar 979 handle = profile.Handle 980 } 981 982 conn, err := upgrader.Upgrade(w, r, nil) 983 if err != nil { 984 log.Printf("WebSocket upgrade failed: %v", err) 985 return 986 } 987 988 room := h.CollaborationHub.GetOrCreateRoom(rKey) 989 wsClient := collaboration.NewClient(h.CollaborationHub, conn, did, name, handle, color, avatar, rKey) 990 room.RegisterClient(wsClient) 991 992 go wsClient.WritePump() 993 wsClient.ReadPump() 994} 995 996func (h *Handler) validateWSToken(accessToken, dpopProof string) (string, string, error) { 997 claims := &jwt.MapClaims{} 998 parser := jwt.Parser{} 999 _, _, err := parser.ParseUnverified(accessToken, claims) 1000 if err != nil { 1001 return "", "", fmt.Errorf("parse token: %w", err) 1002 } 1003 1004 did, ok := (*claims)["sub"].(string) 1005 if !ok { 1006 return "", "", fmt.Errorf("no sub in token") 1007 } 1008 1009 user, err := h.DB.GetUserByDID(did) 1010 if err != nil { 1011 return "", "", fmt.Errorf("user not found: %w", err) 1012 } 1013 1014 session, err := h.DB.GetATProtoSession(user.ID) 1015 if err != nil { 1016 return "", "", fmt.Errorf("session not found: %w", err) 1017 } 1018 1019 if time.Now().After(session.ExpiresAt) { 1020 return "", "", fmt.Errorf("session expired") 1021 } 1022 1023 name, _ := (*claims)["name"].(string) 1024 return did, name, nil 1025} 1026 1027func colorFromDID(did string) string { 1028 colors := []string{"#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"} 1029 hash := 0 1030 for _, c := range did { 1031 hash += int(c) 1032 } 1033 return colors[hash%len(colors)] 1034} 1035 1036// SubmitSteps receives ProseMirror steps from a collaborator, appends them 1037// to the step log, and broadcasts confirmed steps to the room. 1038// 1039// POST /api/docs/{rkey}/steps 1040// Body: {"clientVersion": N, "steps": ["...json..."], "clientID": "did:..."} 1041// Response 200: {"version": N} 1042// Response 409: {"version": N, "steps": ["...json..."]} — client must rebase 1043func (h *Handler) SubmitSteps(w http.ResponseWriter, r *http.Request) { 1044 user, _ := h.currentUser(r) 1045 if user == nil { 1046 http.Error(w, "Unauthorized", http.StatusUnauthorized) 1047 return 1048 } 1049 rkey := r.PathValue("rkey") 1050 1051 var body struct { 1052 ClientVersion int `json:"clientVersion"` 1053 Steps []string `json:"steps"` 1054 ClientID string `json:"clientID"` 1055 } 1056 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 1057 http.Error(w, "Bad request", http.StatusBadRequest) 1058 return 1059 } 1060 if len(body.Steps) == 0 { 1061 http.Error(w, "No steps", http.StatusBadRequest) 1062 return 1063 } 1064 1065 newVersion, err := h.DB.AppendSteps(rkey, body.ClientVersion, body.Steps, body.ClientID) 1066 if err != nil { 1067 // Version conflict — return steps the client missed. 1068 missed, dbErr := h.DB.GetStepsSince(rkey, body.ClientVersion) 1069 if dbErr != nil { 1070 log.Printf("SubmitSteps: GetStepsSince: %v", dbErr) 1071 http.Error(w, "Internal error", http.StatusInternalServerError) 1072 return 1073 } 1074 currentVersion, _ := h.DB.GetDocVersion(rkey) 1075 stepJSONs := make([]string, len(missed)) 1076 for i, s := range missed { 1077 stepJSONs[i] = s.JSON 1078 } 1079 w.Header().Set("Content-Type", "application/json") 1080 w.WriteHeader(http.StatusConflict) 1081 json.NewEncoder(w).Encode(map[string]interface{}{ 1082 "version": currentVersion, 1083 "steps": stepJSONs, 1084 }) 1085 return 1086 } 1087 1088 // Broadcast to other room members via WebSocket. 1089 if room := h.CollaborationHub.GetRoom(rkey); room != nil { 1090 type stepsMsg struct { 1091 Type string `json:"type"` 1092 Steps []string `json:"steps"` 1093 Version int `json:"version"` 1094 ClientID string `json:"clientID"` 1095 } 1096 data, _ := json.Marshal(stepsMsg{ 1097 Type: "steps", 1098 Steps: body.Steps, 1099 Version: newVersion, 1100 ClientID: body.ClientID, 1101 }) 1102 room.Broadcast(data) 1103 } 1104 1105 h.jsonResponse(w, map[string]int{"version": newVersion}, http.StatusOK) 1106} 1107 1108// GetSteps returns all steps since the given version. 1109// 1110// GET /api/docs/{rkey}/steps?since={v} 1111// Response 200: {"version": N, "steps": ["...json..."]} 1112func (h *Handler) GetSteps(w http.ResponseWriter, r *http.Request) { 1113 user, _ := h.currentUser(r) 1114 if user == nil { 1115 http.Error(w, "Unauthorized", http.StatusUnauthorized) 1116 return 1117 } 1118 rkey := r.PathValue("rkey") 1119 1120 sinceStr := r.URL.Query().Get("since") 1121 var since int 1122 if sinceStr != "" { 1123 fmt.Sscanf(sinceStr, "%d", &since) 1124 } 1125 1126 rows, err := h.DB.GetStepsSince(rkey, since) 1127 if err != nil { 1128 log.Printf("GetSteps: %v", err) 1129 http.Error(w, "Internal error", http.StatusInternalServerError) 1130 return 1131 } 1132 version, _ := h.DB.GetDocVersion(rkey) 1133 stepJSONs := make([]string, len(rows)) 1134 for i, s := range rows { 1135 stepJSONs[i] = s.JSON 1136 } 1137 h.jsonResponse(w, map[string]interface{}{ 1138 "version": version, 1139 "steps": stepJSONs, 1140 }, http.StatusOK) 1141}