Files
notas-online/README.md
T
lgcosta 61018a90b5
ci / check (push) Successful in 1m7s
feat(files): document CLI uploads
Document Bearer-authenticated multipart uploads with curl and Wget2. Return 413 for oversized multipart bodies and report attachment size limits accurately.
2026-11-06 08:35:19 -03:00

405 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notas — editor de notas web estilo Mousepad
Aplicação web completa para gerenciamento de notas inspirada no visual sóbrio e minimalista do
**Mousepad** (editor de texto do XFCE): barra de menus superior, barra de ferramentas, área de
texto limpa e barra de status na parte inferior.
Backend único em **Go** (binário estático de ~7 MB com o frontend já embutido), armazenamento em
disco no estilo **Git** (blobs endereçados por SHA-256 + histórico de revisões com diff e
restauração) e autenticação por **token** (Bearer para API / cookie HttpOnly para o navegador).
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ☰ Notas Arquivo Editar Exibir Ajuda │ ← barra de menus
├──────────────────────────────────────────────────────────────────────────┤
│ ✚Nova 💾Salvar ⬇Baixar │ B I S H1 H2 ≡• 1 ❝ 🔗 </> { } │ Formato ▾ │ ← barra de ferramentas
│ [ Editar | Preview ] 🕘Versões │
├─────────────────┬────────────────────────────────────────────────────────┤
│ Buscar… │ ┌ nota.md ● ─┬─ outra.md ─┐ ✚ │ ← abas │
│ CATEGORIAS +pasta├─────────────┴─────────────┴──────────┴────────────────┤
│ 🗂 Todas as notas│ Título da nota 🗀 devops +1 ▾ │
│ 🗄 Sem categoria ├────────────────────────────────────────────────────────┤
│ 📂 devops 1 │ 1 │ # Placas │
│ · 📁 k8s 2 │ 2 │ │
│ TAGS │ 3 │ **nvme** em `/dev/nvme0` │ ← números de linha, │
│ #linux 2 #devops1│ 4 │ - controladora #linux │ cores, marcador │
│ NOTAS 3 │ 5 │ … ┊ │ de coluna (80) │
│ nota1 .md │ │
│ nota2 .txt │ │
├─────────────────┴────────────────────────────────────────────────────────┤
│ nota.md .md Ln 4, Col 12 128 caracteres · 20 palavras ● não salvo│ ← barra de status
└──────────────────────────────────────────────────────────────────────────┘
```
No celular (≤760px) a barra lateral vira **gaveta** com backdrop e o painel de versões
ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de ícone.
## Funcionalidades
- **3 formatos por nota**: Texto puro (`.txt`), Markdown (`.md`) e HTML (`.html`), alternáveis no
editor; a nota é baixada com a extensão correspondente.
- **Barra de ferramentas de formatação**: negrito, itálico, tachado, títulos H1/H2, listas,
citação, link, código inline e bloco de código — inserindo sintaxe Markdown **ou** HTML conforme
o formato ativo (desabilitada em texto puro).
- **Modo Edição ↔ WYSIWYG ↔ Preview** (`Ctrl+E/M/R`): o WYSIWYG (`Ctrl+M`) renderiza a nota num
`contenteditable` e sincroniza com o fonte a cada tecla — Markdown vira HTML editável (negrito,
títulos, listas, citação, código, links, tabelas) e volta a Markdown ao editar/alternar; HTML é
editado visualmente com tags/atributos preservados (scripts e `on*` são removidos ao abrir).
O Preview renderiza Markdown no servidor (goldmark, GFM, HTML cru escapado); HTML em
`<iframe sandbox>`; texto puro como está. Em texto puro o WYSIWYG não se aplica.
- **Categorias como árvore de pastas**: o painel lateral traz um explorador de arquivos —
categorias aninhadas (subpastas), expansão/colapso, contagem de notas por pasta e menu de
contexto (Nova subpasta / Renomear / Excluir, sem ciclos). Uma nota pode pertencer a
**várias categorias** ao mesmo tempo (seletor multi-marca no topo do editor). Filtro por
pasta é recursivo por padrão (`?recursive=0` desliga); há também "Todas as notas" e
"Sem categoria". Dados antigos com o campo único `category` são migrados automaticamente
na abertura (vira categoria raiz de mesmo nome).
- **Tags `#tag`** extraídas automaticamente do conteúdo (cabeçalhos Markdown como `# Título`
não contam — só `#tag` sem espaço), com filtro por nuvem de tags e busca em
título/categorias/conteúdo.
- **Abas de arquivos**: abra várias notas ao mesmo tempo (clique na lista, `Ctrl+N` ou botão
✚); cada aba tem estado sujo próprio (●), botão ✕ com confirmação, fechar com clique do
meio, trocar com `Alt+←/→` ou `Alt+1..9`, fechar com `Alt+W`. As abas abertas (inclusive
rascunhos não salvos) são restauradas entre sessões via `localStorage`.
- **Preferências do editor** (menu Editar → Preferências… ou `Ctrl+,`, em diálogo próprio):
- **Números de linha** (gutter alinhado mesmo com quebra de linha; alternável pelo menu Exibir).
- **Marcador de coluna** customizável — padrão **80** (linha vertical no editor, exibida com
quebra de linha desligada, como no Mousepad/gedit).
- **Quebrar linha** ligado/desligável (alternável também pelo menu Exibir).
- **Tecla Tab**: insere N espaços (padrão **4**, configurável 1–16) **ou** o caractere de
tabulação `\t`; o valor também define a largura exibida do `\t` (`tab-size`) no editor e no
realce, mantendo as camadas alinhadas.
- **Fonte** (DejaVu Sans Mono, Ubuntu Mono, Cascadia Code, Liberation Mono, Courier New ou
monoespaçada do sistema) e **tamanho** (10–32 px).
- **Esquema de cores / tema** (21 opções, em grupos no diálogo): nativos — Claro (Greybird),
Escuro (Adwaita), Solarizado claro/escuro; **iTerm2-Color-Schemes** (projeto
[mbadolato/iTerm2-Color-Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes), MIT) —
escuros: Dracula, Nord, Atom One Dark, Monokai Pro, Gruvbox Dark, Tokyo Night, Catppuccin
Mocha, Tomorrow Night, Ayu Mirage, Kanagawa Wave; claros: Catppuccin Latte, Gruvbox Light,
Atom One Light, Nord Light, Carbon Paper, Tomorrow, Ayu Light. As cores de UI **e** dos
tokens de sintaxe são derivadas do Background/Foreground + ANSI 0–15 de cada arquivo
`.itermcolors`; cada tema também declara `color-scheme` (scrollbars/controles nativos
acompanham). Aplicam-se a toda a interface, incluindo preview e diffs.
- **Colorização de sintaxe**: Markdown (títulos, negrito/itálico, código inline e blocos ` ``` `,
listas, citações, links, tags `#tag`, URLs) e HTML (tags, atributos, strings, comentários,
entidades) renderizados por overlay sobre o textarea; desligável.
- Tudo persistido no navegador (`localStorage`), com pré-visualização ao vivo no diálogo,
botões Cancelar (reverte) e Restaurar padrões.
- **Versionamento estilo Git**: cada salvamento de conteúdo cria uma revisão imutável
(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 por ociosidade**: só grava após um período **sem nenhuma alteração** — padrão
**10 s**, configurável (5/10/30/60 s em Preferências); cada tecla reinicia a contagem, então
digitação contínua nunca é interrompida por gravações. Silencioso (sem toast — apenas o
status *"salvando…/salvo"*; toast continua no `Ctrl+S` manual). A gravação também é feita
ao **trocar de aba do app** ou ao **minimizar/trocar de janela** (`visibilitychange`/
`pagehide`), para não perder edição pendente. Conflitos 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:
*Recarregar do servidor* (descarta o texto local) ou *Salvar por cima* (a versão remota fica
no histórico e é restaurável).
- Abas **sem edição pendente** são recarregadas automaticamente quando a janela volta ao foco
(`visibilitychange`/`focus`, com debounce), com aviso discreto.
- **Autenticação por token**: sem token, a interface exibe um diálogo pedindo o token; ele é
guardado em **cookie HttpOnly/SameSite=Lax** (30 dias). A API também aceita
`Authorization: Bearer <token>` para scripts.
- **Responsivo para celular** (≤760px): barra lateral vira gaveta com backdrop (☰ fecha ao
tocar uma nota), painel de versões ocupa a tela toda, **toolbar quebra em 3 linhas
organizadas** — ① ações de arquivo + formato + versões, ② alternador de modo em largura
total, ③ ferramentas de formatação distribuídas (cabe em 393px **e** 320px sem corte) —,
alvos de toque ≥40px, diálogos em largura total, fonte do editor com mínimo de 16px (evita
zoom automático do iOS), altura em `dvh` e `safe-area-inset` para notch; **dropdowns dos
menus em `position:fixed`** (o `overflow-x` da menubar não os corta mais) e menus rolam
horizontalmente sem rótulos.
- **Botões "rich"** (linguagem visual do Rich Button/spell.sh, mas em CSS puro — o componente
deles é React+Tailwind, incompatível com o build-free + CSP do app): gradiente vertical com
brilho de vidro, reflexo interno superior, sombra dupla e glow no primário, micro-interações
de hover (eleva) / active (pressiona) / focus ring. Tudo derivado dos tokens com
`color-mix()` + `light-dark()`, então **os 21 temas** (claros e escuros) ganham botões
coerentes automaticamente; o mesmo vale para o controle segmentado Editar/WYSIWYG/Preview,
selects da toolbar e chips.
- **Estilo Mousepad**: tema claro Greybird-like, menus nativos (Arquivo/Editar/Exibir/Ajuda),
atalhos `Ctrl+S`, `Ctrl+N`, `Ctrl+E/M/R` (edição/wysiwyg/preview), `Ctrl+H` (histórico), `F9`
(barra lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), `Alt+W`/`Alt+←/→`/
`Alt+1..9` (abas), barra de status com arquivo, formato, linha/coluna, contagem de palavras e
estado de salvamento.
## Estrutura do projeto
```
.
├── cmd/notas/ # main: flags, env, graceful shutdown
├── internal/
│ ├── store/ # persistência em disco, revisões (estilo Git) e árvore de categorias
│ ├── diffx/ # diff unificado (gotextdiff/Myers)
│ └── api/ # rotas HTTP, auth, CRUD de categorias, render de Markdown
├── web/ # frontend estático (go:embed no binário)
│ ├── index.html css/app.css js/app.js
├── Dockerfile # multi-stage: golang:alpine → scratch
├── docker-compose.yml
└── .env.example
```
## Como rodar com Docker (recomendado)
```bash
cp .env.example .env
# edite .env e defina o token (gere um forte):
# openssl rand -hex 24
docker compose up -d
# abra http://localhost:8080 e informe o token na tela de login
```
Se a porta 8080 estiver ocupada: `NOTAS_PORT=9090 docker compose up -d`.
Com **Podman**: tudo acima funciona igualmente com `podman-compose`.
Os dados ficam no diretório local **`./data`** (bind mount, ao lado do
`docker-compose.yml`) — já ignorado pelo `.gitignore`. Backup = copiar a pasta;
`docker compose down` não apaga nada.
Permissões do `./data` (a imagem roda como uid 65534, non-root):
- **Podman rootless**: adicione `NOTAS_USER=0:0` ao `.env` — dentro do container
isso é mapeado para o **seu** usuário no host, que é dono de `./data`.
- **Docker (root)**: `sudo chown -R 65534:65534 data` (ou rode o container com
`NOTAS_USER=$(id -u):$(id -g)` e ajuste a dona da pasta em conformidade).
## Como rodar sem Docker
```bash
export NOTES_TOKEN=$(openssl rand -hex 24)
go run ./cmd/notas # escuta :8080, dados em ./data
# ou o binário:
go build -trimpath -ldflags="-s -w" -o notas ./cmd/notas
./notas -addr :8080 -data ./data
```
### Configuração (variáveis de ambiente / flags)
| Variável | Flag | Padrão | Descrição |
|------------------|----------|-----------|--------------------------------------------|
| `NOTES_TOKEN` | — | (sem) | **Obrigatório.** Token de acesso. |
| `NOTES_DATA_DIR` | `-data` | `./data` | Diretório de dados (notas + blobs). |
| `NOTES_ADDR` | `-addr` | `:8080` | Endereço de escuta. |
## API (para scripts)
Todos os endpoints sob `/api/*` exigem o token, exceto `login/logout/me` e o frontend.
```bash
T=$NOTES_TOKEN; B=http://localhost:8080
curl -H "Authorization: Bearer $T" $B/api/notes # lista (?category=<id|none>&recursive=0&tag=&q=)
curl -H "Authorization: Bearer $T" $B/api/notes/<id> # nota + conteúdo
curl -s -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"# Oi #linux"}' $B/api/notes
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"editado","author":"lg","message":"revisão 2","base_hash":"<hash carregado>"}' \
$B/api/notes/<id>
# base_hash é opcional: se enviado e diferente do atual → 409 {error, server_hash, updated_at}
# (omitir/enviar vazio = força o salvamento)
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions # histórico
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/diff # diff unificado
curl -X POST -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/restore \
-H 'Content-Type: application/json' -d '{"author":"lg"}' # restaurar
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/raw # download .txt/.md/.html
curl -H "Authorization: Bearer $T" $B/api/tags $B/api/categories # agregações
# árvore de categorias (lista plana; parent="" é raiz)
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"devops","parent":""}' $B/api/categories
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"infra","parent":"<novo-pai>"}' $B/api/categories/<id> # renomear/mover
curl -X DELETE -H "Authorization: Bearer $T" $B/api/categories/<id> # 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":["<id>"]}' $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)
```
## PWA — instalável e offline
O app é um **PWA completo**: instalável no Linux/Windows/macOS/Android (menu **Ajuda →
Instalar Aplicativo…**, ou o ícone da barra de endereço), com janela `standalone` sem chrome
do navegador e atalho de área de trabalho "Nova nota" (`/?new=1`).
- **`web/manifest.webmanifest`** — `name/short_name/description/start_url/scope/theme_color/
background_color`, `display: standalone` (+ `display_override` com window-controls-overlay),
ícones **192/512 em `any`, `maskable` e `monochrome`** (PNGs reais em `web/icons/`) e atalho.
- **`web/sw.js`** — Service Worker com cache versionado (`notas-shell-v1`):
- `install` pré-cacheia o shell e `skipWaiting`; `activate` expurga caches antigos,
`clients.claim()` e avisa as janelas (`notas:updated` → toast "App atualizado");
- navegação: **network-first com fallback ao shell** (abre offline);
- estáticos: **stale-while-revalidate** (carregamento instantâneo);
- **`/api/*` nunca é interceptado** — dados sempre frescos e o SSE de tempo real não quebra.
- **Ao publicar mudanças de assets, bump na constante `CACHE`** do `sw.js`.
- **Meta tags** em `index.html`: `theme-color`, `mobile-web-app-capable`,
`apple-mobile-web-app-*`, `apple-touch-icon` e `<link rel="manifest">`.
- **Offline UX**: shell + interface funcionam sem rede; ações que precisam do servidor mostram
toast claro ("Sem conexão — o app abre offline; salvar precisa do servidor").
### Compatibilidade de instalação por navegador
| Navegador | Service Worker / offline | Instalação | Botão "Instalar Aplicativo…" |
|---|---|---|---|
| Chrome/Edge/Brave/Vivaldi | ✅ | `beforeinstallprompt` (prompt nativo) | ✅ dispara o prompt |
| Firefox Android | ✅ | menu **⋮ → Instalar** (tela inicial) | ✅ abre instruções |
| Firefox Windows 143+ | ✅ | **fixar na barra de tarefas** pela barra de endereço | ✅ abre instruções |
| Firefox Linux 155+ | ✅ | `about:config` → `browser.taskbarTabs.enabled=true` → *Adicionar aba à barra de tarefas* (desligado por padrão) | ✅ abre instruções |
| Firefox macOS | ✅ (app pleno em aba/offline) | ainda sem fluxo nativo (em desenvolvimento na Mozilla) | ✅ abre instruções honestas |
| Safari (macOS/iOS) | ✅ | manual: Compartilhar → *Adicionar ao Dock/Tela de Início* | escondido (sem evento) |
O código usa a checagem de propriedade `'onbeforeinstallprompt' in window` (o evento **nunca**
dispara em Firefox/Safari — MDN), então no Firefox o item do menu sempre aparece e mostra o
passo a passo da plataforma; em standalone (app instalado) o item se esconde sozinho via
`display-mode: standalone`, que o Firefox também reporta.
### Servindo os arquivos (backend Go atual)
Já implementado em `internal/api/server.go` (`staticHandler`):
- `sw.js` → `Cache-Control: no-cache, no-store, must-revalidate` + `Content-Type:
text/javascript` (a detecção de update do SW é por bytes — nunca cacheie!);
- `manifest.webmanifest` → `Content-Type: application/manifest+json; charset=utf-8`
(o Go não conhece a extensão `.webmanifest` por padrão);
- tudo vem do `go:embed` (`web/embed.go` inclui `manifest.webmanifest sw.js icons`).
- O Service Worker só registra em origem segura: **HTTPS ou `localhost`/`127.0.0.1`** — em IP
de LAN puro (`http://192.168.x.x`) o navegador bloqueia SW; use um túnel/reverse proxy TLS
para testar instalação real fora de localhost.
### Equivalente em Rust (axum, caso o backend migre)
```rust
// MIME do manifest + no-cache no SW
async fn manifest() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/manifest+json")], include_str!("../web/manifest.webmanifest"))
}
async fn sw() -> impl IntoResponse {
[
(header::CONTENT_TYPE, "text/javascript"),
(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"),
]
}
```
No actix-web, o `fs::Files` já define MIME por extensão, mas registre antes:
`mime::BABFILE` não — use `mime_guess::init::detect_EXTENSION("webmanifest",
"application/manifest+json")` e um `wrap_fn` que sobrepõe `Cache-Control` quando o caminho for
`/sw.js`. (No `tower-http` `SetResponseHeaderLayer` com `Match` por path é o caminho limpo.)
## Gerenciador de arquivos (anexos)
Árvore de pastas estilo sistema de arquivos + arquivos com **associação opcional
a uma nota** (sem nota = avulso). Abra com **Ctrl+J**, menu **Arquivo →
Gerenciador de arquivos…** ou o botão **📎 Anexo** (modo seletor: clicar num
arquivo insere no editor e vincula à nota).
- **Persistência (disc)**: `data/folders.json` (árvore `parent`), `data/files.json`
(metadados: nome, pasta, nota, tamanho, MIME, SHA-256) e blobs em
`data/files/<id>.<ext>` — a extensão física de tipos perigosos (html/js/svg) é
neutralizada (`.bin`/`.img`); o MIME real só vive no metadado.
- **API**:
- `POST /api/files/upload` — multipart (`file`, opcionais `folder_id`, `note_id`) → 201 com `url`
- `GET /api/files/tree` — hierarquia JSON (pastas aninhadas + arquivos + raiz)
- `GET /api/files/static/{id}` — serve com MIME real, **ETag (SHA-256) e Range**
(vídeos fazem seek), `CSP: sandbox` + `nosniff`; tipos não-inline-seguros
(html/svg/js…) vão como **`Content-Disposition: attachment`** — anexar um
`.html` malicioso **não executa same-origin** (stored-XSS mitigado)
- `PUT /api/files/{id}/move` — `{folder, note, name}` (mover/associar/desvincular/renomear)
- `DELETE /api/files/{id}` · `POST/PUT/DELETE /api/folders[/{id}]` (com anti-ciclo)
- Upload pela linha de comando com `curl` ou GNU `wget`: veja [UPLOAD-CLI.md](UPLOAD-CLI.md).
- **Editor**: colar (Ctrl+V) imagem/arquivo do clipboard faz upload automático e
insere `![…](url)` / `![]()` / link conforme o formato; arrastar-e-soltar
funciona no editor e no gerenciador; no WYSIWYG a inserção é visual (`<img>`/
`<video>`) e volta a Markdown na serialização; preview no gerenciador abre
lightbox com `<img>`/`<video>`/`<audio>`/PDF (iframe) ou texto puro.
### Instruções de teste
```bash
# 1) subir o servidor e criar uma nota pela UI (ou curl)
export T=seu-token; B=http://127.0.0.1:8080
# 2) upload avulso e em pasta
FID=$(curl -s -X POST $B/api/files/folders -H "Authorization: Bearer $T" \
-H 'Content-Type: application/json' -d '{"name":"docs","parent":""}' | jq -r .id)
curl -s -X POST $B/api/files/upload -H "Authorization: Bearer $T" \
-F file=@foto.png -F folder_id=$FID | jq .
# 3) árvore + estático com Range/headers
curl -s $B/api/files/tree -H "Authorization: Bearer $T" | jq
curl -sI -H "Authorization: Bearer $T" -H 'Range: bytes=0-9' $B/api/files/static/<id>
# 4) XSS guard: anexar .html deve vir attachment
printf '<script>alert(1)</script>' > x.html
curl -s -X POST $B/api/files/upload -H "Authorization: Bearer $T" -F file=@x.html | jq -r .id
curl -sI -H "Authorization: Bearer $T" $B/api/files/static/<id> | grep -i disposition
# 5) UI: Ctrl+J abre o gerenciador; cole uma imagem (Ctrl+V) no editor e arraste
# arquivos para dentro dele; use 📎 para inserir um arquivo existente.
```
## Como funciona o versionamento
- O conteúdo é guardado em `data/blobs/<aa>/<hash-sha256>` (content-addressed, como objetos do
Git): conteúdo repetido ocupa um único blob.
- Cada nota tem `data/notes/<id>/meta.json` (estado atual) e `history.json` (lista ordenada de
revisões: `hash`, `parent`, `author`, `when`, `message`, `bytes`).
- A árvore de categorias fica em `data/categories.json` (lista plana com `parent`; nomes únicos
entre irmãos; mover para dentro de si mesma é recusado).
- Salvar sem alterar o conteúdo atualiza apenas os metadados (título/categorias/formato) — sem
revisão nova. Restaurar cria uma revisão nova apontando para o blob antigo.
- Apagar uma nota faz GC dos blobs que ficaram sem referência.
## Segurança
- Token verificado em **tempo constante**; nunca é registrado nos logs. **Login tem rate-limit por
IP** (5 tentativas erradas em 15 min → 429; acerto reseta; log de falha com IP).
- Sessão do navegador em cookie **HttpOnly + SameSite=Lax** contendo um **id de sessão opaco**
(`crypto/rand`, 32 bytes) — o token mestre nunca trafega em cookie. Logout **revoga** a sessão no
servidor; reiniciar o processo revoga todas (relogin). O token mestre vale apenas via
`Authorization: Bearer` (scripts/CLI).
- **Cookie `Secure` é decisão de configuração** (`NOTES_SECURE_COOKIE=true` no `.env`), nunca de
`X-Forwarded-Proto` do cliente (spoofável). Ative quando o acesso é sempre por HTTPS.
- CSP restritiva (`default-src 'self'`), `X-Frame-Options: DENY`, `nosniff`; preview de HTML roda
em iframe `sandbox` sem mesma origem; Markdown renderiza com HTML cru escapado; o editor
WYSIWYG neutraliza `href/src` com esquemas perigosos (`javascript:`, `vbscript:`, `data:`).
- Limite de 2 MiB por nota; corpo HTTP limitado a 4 MiB; `/api/render` aceita no máximo 512 KiB de
entrada (a conversão amplifica ~2×); upload limitado a 64 MiB **no corpo inteiro** e a
**2 GiB de total** de anexos por instância (quota contra esgotamento de disco).
- Rode atrás de TLS (reverse proxy) em produção. O modelo é **token único compartilhado**
(sem usuários múltiplos); a imagem final é `scratch` non-root (uid 65534) — sem shell para
`exec` de depuração, por design.
- **Notas operacionais.** Rate-limit e sessões vivem **em memória**: reiniciar o processo
limpa bloqueios de login e revoga todas as sessões (relogin). Atrás de proxy reverso,
todos os clientes chegam com o IP do proxy — o bloqueio passa a ser compartilhado;
só confie em cabeçalhos `X-Forwarded-*` com lista explícita de proxies confiáveis
(hoje o servidor usa `RemoteAddr` puro). O boot roda reconciliação de anexos
(remove blobs órfãos, loga metadados sem blob). SSE é stream longo sem deadline
global de escrita (renovado por keepalive). Erros de corpo acima do limite
retornam **413**; JSON malformado retorna **400**.
- 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
```bash
go test ./... # store (revisões/GC/tags/árvore de categorias/migração),
# diffx (diff unificado), api (fluxo HTTP completo + CRUD de categorias)
go vet ./...
node --check web/js/app.js
```