From a55591d2084f5a22824248a94842d4a6a6d769ae Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Costa Date: Wed, 4 Nov 2026 13:35:33 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20presen=C3=A7a=20por=20sess=C3=A3o,=20ev?= =?UTF-8?q?entos=20SSE=20em=20tempo=20real=20e=20autosave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - presence.go: hub SSE + registro de presença (TTL 30s por heartbeat) - GET /api/events (EventSource, auth cookie/Bearer) transmite saved/presence; POST /api/presence publica {session, notes[]}; create/update/restore/delete publicam savedEvent com a session de origem (eco ignorado pelo autor) - PUT/POST/restore aceitam campo opcional session - UI: id de sessão em sessionStorage; selo 👥 na aba, na lista de notas e no topo do editor quando outra sessão tem a mesma nota aberta; aba LIMPA recarrega sozinha ao receber saved; aba SUJA abre o diálogo de conflito automaticamente (conflictPending evita diálogos empilhados) - Autosave: debounce de 1,5s em qualquer alteração (rascunho vira nota), mensagem de revisão 'autosave', status 'salvando…', toggle nas Preferências - Testes: SSE hello/presence/saved + presença 400/401 via httptest --- README.md | 25 ++++ internal/api/api_test.go | 77 ++++++++++++ internal/api/notes.go | 12 +- internal/api/presence.go | 251 ++++++++++++++++++++++++++++++++++++++ internal/api/revisions.go | 4 +- internal/api/server.go | 6 + web/css/app.css | 14 +++ web/index.html | 2 + web/js/app.js | 167 +++++++++++++++++++++++-- 9 files changed, 543 insertions(+), 15 deletions(-) create mode 100644 internal/api/presence.go diff --git a/README.md b/README.md index 40cc201..973d0bc 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,20 @@ ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de (autor, data, mensagem, hash SHA-256 e parent). O painel de histórico mostra o **diff unificado** de cada revisão e permite **restaurar** qualquer versão anterior (a restauração vira uma nova revisão — nada se perde). Conteúdo idêntico é deduplicado (content-addressed). +- **Colaboração em tempo real entre dispositivos/sessões** (SSE, sem recarregar página): + - **Presença**: cada janela/aba do navegador é uma *sessão* (id aleatório em `sessionStorage`) + que envia heartbeat das notas abertas (`POST /api/presence`). Quando outra sessão está com a + mesma nota aberta, aparece **👥** na aba, na lista de notas e um selo + *"outra sessão aberta aqui"* no topo do editor. + - **Propagação de salvamento**: criar/salvar/restaurar/excluir publica um evento + `{type:"saved", note_id, hash, session}` no stream SSE (`GET /api/events`). Instância com a + nota **limpa** recarrega o conteúdo sozinha (toast discreto); com a nota **suja**, abre + automaticamente o diálogo *Conflito de edição* (recarregar | salvar por cima). A própria + sessão que salvou ignora o evento; reconexão do `EventSource` é automática e o fallback + continua sendo a checagem no foco da janela. +- **Autosave**: ligado por padrão — após ~1,5 s sem digitar, a alteração é salva como revisão + `autosave` (rascunho novo vira nota criada automaticamente; status *"salvando…"* na barra). + Desligável em Preferências. Conflitos durante autosave pausam o ciclo até a resolução. - **Sincronia entre dispositivos** (celular + desktop no mesmo servidor): - Cada aba lembra o hash em que se baseia (`base_hash`). Ao salvar, se o servidor mudou desde então, a API responde **409** e a UI abre o diálogo **Conflito de edição** com as duas saídas: @@ -189,6 +203,14 @@ curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ -d '{"name":"infra","parent":""}' $B/api/categories/ # renomear/mover curl -X DELETE -H "Authorization: Bearer $T" $B/api/categories/ # remove subtree +# tempo real (SSE): stream de eventos saved/presence (auth por cookie no navegador; +# para scripts, leia o stream com o header Bearer) +curl -N -H "Authorization: Bearer $T" "$B/api/events?session=meu-id" +# heartbeat de presença: informa quais notas a sessão tem abertas +curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ + -d '{"session":"meu-id","notes":[""]}' $B/api/presence +# PUT/POST/restore aceitam campo opcional "session" — o eco vai no evento saved +# (clientes que ignoram o evento podem sobrescrever a própria edição sem querer) ``` ## Como funciona o versionamento @@ -217,6 +239,9 @@ curl -X DELETE -H "Authorization: Bearer $T" $B/api/categories/ # `exec` de depuração, por design. - Preferências do editor e abas abertas ficam em `localStorage` (dados não sensíveis, por dispositivo/navegador); o token nunca vai para lá. +- Presença/SSE: o stream exige a mesma autenticação das rotas `/api/*` (cookie HttpOnly no + navegador, Bearer em scripts). O registro de presença guarda apenas id de sessão aleatório + + ids de notas abertas — nada de conteúdo trafega no canal de presença. ## Testes diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ae6bf51..2f611fb 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,6 +1,7 @@ package api_test import ( + "bufio" "bytes" "encoding/json" "io" @@ -10,6 +11,7 @@ import ( "strings" "testing" "testing/fstest" + "time" "github.com/lgcosta/notas-online/internal/api" "github.com/lgcosta/notas-online/internal/store" @@ -377,6 +379,81 @@ func TestSaveConflictViaAPI(t *testing.T) { resp.Body.Close() } +func TestPresenceAndSSEEvents(t *testing.T) { + srv := newTestServer(t) + call := func(method, path, body string) *http.Response { + return do(t, srv, method, path, body, testToken, "") + } + + // abre o stream SSE + req, err := http.NewRequest("GET", srv.URL+"/api/events?session=s1", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+testToken) + streamResp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer streamResp.Body.Close() + if ct := streamResp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("content-type SSE: %q", ct) + } + sc := bufio.NewScanner(streamResp.Body) + + readEvent := func(contains string) string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for sc.Scan() && time.Now().Before(deadline) { + line := sc.Text() + if strings.HasPrefix(line, "data: ") && strings.Contains(line, contains) { + return line + } + } + if err := sc.Err(); err != nil { + t.Fatalf("scanner SSE: %v", err) + } + t.Fatalf("evento contendo %q não chegou", contains) + return "" + } + + // hello inicial com snapshot de presença + readEvent(`"type":"hello"`) + + // heartbeat de presença → broadcast presence com s2 + resp := call("POST", "/api/presence", `{"session":"s2","notes":["nota1"]}`) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("presence: %s", resp.Status) + } + readEvent(`"type":"presence"`) + + // presença sem session → 400 + resp = call("POST", "/api/presence", `{"notes":[]}`) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("presence sem session: %s", resp.Status) + } + resp.Body.Close() + + // criar nota com session → evento saved para os ouvintes + resp = call("POST", "/api/notes", `{"title":"E","format":"txt","categories":[],"content":"x","author":"","message":"","session":"s3"}`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create: %s", resp.Status) + } + var created noteView + decode(t, resp, &created) + ev := readEvent(`"type":"saved"`) + if !strings.Contains(ev, `"session":"s3"`) || !strings.Contains(ev, created.ID) { + t.Errorf("evento saved incompleto: %s", ev) + } + + // SSE sem token → 401 + resp = do(t, srv, "GET", "/api/events", "", "", "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("SSE sem auth: %s", resp.Status) + } + resp.Body.Close() +} + func TestInvalidPayloads(t *testing.T) { srv := newTestServer(t) resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "") diff --git a/internal/api/notes.go b/internal/api/notes.go index 8d1e222..3c59ea8 100644 --- a/internal/api/notes.go +++ b/internal/api/notes.go @@ -30,6 +30,7 @@ type noteInput struct { Author string `json:"author"` Message string `json:"message"` BaseHash string `json:"base_hash"` // hash em que a edição se baseia (opcional; evita sobrescrever mudanças remotas) + Session string `json:"session"` // id da sessão do cliente (presença/eventos; opcional) } func (in noteInput) fields() (string, string, []string, string, string, string, string) { @@ -77,6 +78,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { mapStoreError(w, err) return } + s.publishSaved(n, author, in.Session, false) writeJSON(w, http.StatusCreated, noteView{n, content}) } @@ -110,14 +112,22 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { mapStoreError(w, err) return } + s.publishSaved(n, author, in.Session, false) writeJSON(w, http.StatusOK, noteView{n, content}) } func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { - if err := s.st.Delete(r.PathValue("id")); err != nil { + id := r.PathValue("id") + n, err := s.st.Get(id) + if err != nil { mapStoreError(w, err) return } + if err := s.st.Delete(id); err != nil { + mapStoreError(w, err) + return + } + s.publishSaved(n, "", "", true) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/presence.go b/internal/api/presence.go new file mode 100644 index 0000000..ddf43ca --- /dev/null +++ b/internal/api/presence.go @@ -0,0 +1,251 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lgcosta/notas-online/internal/store" +) + +// --------------------------------------------------------------------------- +// Hub SSE: transmite eventos (saved/presence) para todas as conexões /api/events +// --------------------------------------------------------------------------- + +type hub struct { + mu sync.Mutex + clients map[chan string]struct{} +} + +func newHub() *hub { return &hub{clients: map[chan string]struct{}{}} } + +func (h *hub) register() chan string { + c := make(chan string, 32) + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + return c +} + +func (h *hub) unregister(c chan string) { + h.mu.Lock() + delete(h.clients, c) + h.mu.Unlock() +} + +// broadcast entrega sem bloquear; cliente lento perde eventos (o estado +// completo volta no próximo heartbeat/presence snapshot). +func (h *hub) broadcast(data string) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.clients { + select { + case c <- data: + default: + } + } +} + +// publish serializa v e envia ao hub. +func (s *Server) publish(v any) { + data, err := json.Marshal(v) + if err != nil { + return + } + s.hub.broadcast(string(data)) +} + +// --------------------------------------------------------------------------- +// Registro de presença: sessão → notas abertas (TTL por heartbeat) +// --------------------------------------------------------------------------- + +const presenceTTL = 30 * time.Second + +type presenceEntry struct { + notes []string + when time.Time +} + +type presenceReg struct { + mu sync.Mutex + m map[string]presenceEntry +} + +func newPresenceReg() *presenceReg { + return &presenceReg{m: map[string]presenceEntry{}} +} + +// set grava o estado da sessão; devolve true se o conjunto de notas mudou. +func (p *presenceReg) set(session string, notes []string) bool { + p.mu.Lock() + defer p.mu.Unlock() + old, ok := p.m[session] + p.m[session] = presenceEntry{notes: notes, when: time.Now()} + if !ok { + return true + } + if len(old.notes) != len(notes) { + return true + } + for i := range notes { + if old.notes[i] != notes[i] { + return true + } + } + return false +} + +func (p *presenceReg) remove(session string) bool { + p.mu.Lock() + defer p.mu.Unlock() + _, ok := p.m[session] + delete(p.m, session) + return ok +} + +// snapshot devolve as sessões vivas (podando as expiradas). +func (p *presenceReg) snapshot() []presenceSession { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + out := make([]presenceSession, 0, len(p.m)) + for sid, e := range p.m { + if now.Sub(e.when) > presenceTTL { + delete(p.m, sid) + continue + } + notes := e.notes + if notes == nil { + notes = []string{} + } + out = append(out, presenceSession{Session: sid, Notes: notes}) + } + return out +} + +// --------------------------------------------------------------------------- +// Eventos (payloads) +// --------------------------------------------------------------------------- + +type savedEvent struct { + Type string `json:"type"` // "saved" + NoteID string `json:"note_id"` + Hash string `json:"hash"` + Title string `json:"title"` + Author string `json:"author"` + Session string `json:"session"` // quem salvou (o próprio cliente ignora) + Deleted bool `json:"deleted,omitempty"` +} + +type presenceSession struct { + Session string `json:"session"` + Notes []string `json:"notes"` +} + +type presenceEvent struct { + Type string `json:"type"` // "presence" | "hello" + Sessions []presenceSession `json:"sessions"` +} + +// publishPresence anuncia o snapshot atual a todos os clientes SSE. +func (s *Server) publishPresence() { + s.publish(presenceEvent{Type: "presence", Sessions: s.pres.snapshot()}) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// handleEvents é o stream SSE (EventSource). Auth via cookie (o EventSource +// do navegador envia cookies same-origin automaticamente). +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming não suportado") + return + } + session := truncate(r.URL.Query().Get("session"), 64) + + ch := s.hub.register() + defer func() { + s.hub.unregister(ch) + if session != "" && s.pres.remove(session) { + s.publishPresence() // sumiu o heartbeat/sessão → atualiza todos + } + }() + + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-store") + h.Set("X-Accel-Buffering", "no") + + hello, _ := json.Marshal(presenceEvent{Type: "hello", Sessions: s.pres.snapshot()}) + fmt.Fprintf(w, "data: %s\n\n", hello) + flusher.Flush() + + keepalive := time.NewTicker(25 * time.Second) + defer keepalive.Stop() + for { + select { + case msg := <-ch: + fmt.Fprintf(w, "data: %s\n\n", msg) + flusher.Flush() + case <-keepalive.C: + _, _ = io.WriteString(w, ": ping\n\n") + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +type presenceInput struct { + Session string `json:"session"` + Notes []string `json:"notes"` +} + +// handlePresence recebe o heartbeat {session, notes:[ids abertos]}. +func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { + var in presenceInput + if err := decodeJSON(r, &in, 1<<16); err != nil { + writeError(w, http.StatusBadRequest, "corpo JSON inválido") + return + } + in.Session = truncate(in.Session, 64) + if in.Session == "" { + writeError(w, http.StatusBadRequest, "session é obrigatória") + return + } + notes := in.Notes + if len(notes) > 64 { + notes = notes[:64] + } + for _, n := range notes { + if len(n) > 32 { + writeError(w, http.StatusBadRequest, "id de nota inválido") + return + } + } + if changed := s.pres.set(in.Session, notes); changed { + s.publishPresence() + } + w.WriteHeader(http.StatusNoContent) +} + +// publishSaved anuncia uma alteração de nota às demais sessões. +func (s *Server) publishSaved(n *store.Note, author, session string, deleted bool) { + s.publish(savedEvent{ + Type: "saved", NoteID: n.ID, Hash: n.Hash, Title: n.Title, + Author: author, Session: session, Deleted: deleted, + }) +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/internal/api/revisions.go b/internal/api/revisions.go index a79d242..ab6bc07 100644 --- a/internal/api/revisions.go +++ b/internal/api/revisions.go @@ -75,7 +75,8 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) { } type restoreInput struct { - Author string `json:"author"` + Author string `json:"author"` + Session string `json:"session"` } func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { @@ -101,6 +102,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { mapStoreError(w, err) return } + s.publishSaved(n, in.Author, in.Session, false) writeJSON(w, http.StatusOK, noteView{n, content}) } diff --git a/internal/api/server.go b/internal/api/server.go index 0597bd6..0b6c852 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -29,6 +29,8 @@ type Server struct { token string webFS fs.FS md goldmark.Markdown + hub *hub + pres *presenceReg } // New cria o servidor da API. webFS é o filesystem embutido do frontend @@ -42,6 +44,8 @@ func New(st *store.Store, token string, webFS fs.FS) *Server { goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(htmlr.WithHardWraps()), ), + hub: newHub(), + pres: newPresenceReg(), } } @@ -70,6 +74,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/categories/{id}", s.auth(s.handleUpdateCategory)) mux.HandleFunc("DELETE /api/categories/{id}", s.auth(s.handleDeleteCategory)) mux.HandleFunc("POST /api/render", s.auth(s.handleRender)) + mux.HandleFunc("GET /api/events", s.auth(s.handleEvents)) + mux.HandleFunc("POST /api/presence", s.auth(s.handlePresence)) mux.HandleFunc("GET /{$}", s.serveIndex) mux.Handle("GET /", http.FileServerFS(s.webFS)) diff --git a/web/css/app.css b/web/css/app.css index 42af9c8..f5bf6d6 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -919,3 +919,17 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450; } #wysiwyg:empty::before { content: 'Comece a escrever…'; color: var(--muted); } #wysiwyg [contenteditable="true"]:focus { outline: none; } + +/* ------------------------------------------------- presença e autosave */ +.presence-badge { + align-self: center; + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--warn); + white-space: nowrap; + flex: none; +} +.tab .tab-pres { color: var(--warn); font-size: 10px; flex: none; } +#st-saved.pending { color: var(--accent); font-weight: 600; } diff --git a/web/index.html b/web/index.html index 2d325ae..9668c4a 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,7 @@ +