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