Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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}