ci / check (push) Successful in 57s
Sobe web/index.html + web/js/app.js de verdade em jsdom, intercepta a API e cobre arrastar-e-soltar entre pastas, mover/renomear/excluir, filtros, busca e preview. CI passa a rodar npm ci && npm test.
418 lines
28 KiB
Markdown
418 lines
28 KiB
Markdown
# 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 `` / `![]()` / 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.
|
||
- **Mover entre pastas arrastando**: arraste um arquivo da lista para uma pasta
|
||
da árvore (ou para **Raiz**) para mudá-lo de lugar. A movimentação preserva o
|
||
**id** do arquivo, então as referências das notas (`/api/files/static/{id}`)
|
||
continuam válidas. Soltar na pasta em que o arquivo já está não faz nada.
|
||
|
||
### 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
|
||
|
||
npm ci # dependência de teste do frontend (jsdom)
|
||
npm test # testes do frontend em jsdom: sobe web/index.html + web/js/app.js
|
||
# de verdade, intercepta a API e exercita o gerenciador de
|
||
# arquivos (arrastar-e-soltar, mover/renomear/excluir, filtros,
|
||
# busca e preview de imagem/compactado)
|
||
```
|
||
|
||
Os testes de front-end rodam com o `node --test` nativo (sem runner extra) e
|
||
vivem em `frontend/tests/` — fora de `web/`, que é embutido no binário.
|