1. /api/render: cap de 512 KiB na entrada (amplificação ~2× medida); decodeJSON agora amarra http.MaxBytesReader ao ResponseWriter (413 real ao cliente em todos os endpoints JSON) 2. Login com rate-limit por IP em memória (5 falhas/15 min → 429, acerto reseta, RemoteAddr puro — X-F-F não é confiável) + log de falha com IP 3. Upload: MaxBytesReader no corpo inteiro (64 MiB + 64 KiB) antes do parse do multipart; quota global de 2 GiB (MaxFilesTotalBytes) em AddFile 4. sanitizeRich neutraliza href/src com javascript:/vbscript:/data: (case/whitespace-insensível); link legítimo preservado 5. Cookie Secure passa a ser configuração explícita (NOTES_SECURE_COOKIE) — X-Forwarded-Proto é spoofável; documentado em .env.example e compose 6. Cookie agora carrega id de sessão opaco (crypto/rand 32B, TTL 30d, revogação no logout e no restart); token mestre só vale via Bearer — furo pego pelo próprio teste novo (token no cookie era aceito) Testes: rate-limit, revogação, canais separados, render 413, upload cap sw v16
252 lines
6.1 KiB
Go
252 lines
6.1 KiB
Go
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(w, 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
|
|
}
|