diff --git a/README.md b/README.md
index 93f4f11..ecec9c8 100644
--- a/README.md
+++ b/README.md
@@ -321,8 +321,7 @@ arquivo insere no editor e vincula à nota).
`.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)
- - Limite: 64 MiB/arquivo; nomes sanitizados (`filepath.Base` + whitelist); ids
- gerados — path do disco nunca aparece na URL.
+- 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 (`
`/
diff --git a/UPLOAD-CLI.md b/UPLOAD-CLI.md
new file mode 100644
index 0000000..580afb5
--- /dev/null
+++ b/UPLOAD-CLI.md
@@ -0,0 +1,105 @@
+# Upload de arquivos pela linha de comando
+
+O endpoint de upload já usado pelo gerenciador de arquivos também aceita clientes CLI:
+
+```text
+POST /api/files/upload
+Authorization: Bearer
+Content-Type: multipart/form-data
+```
+
+O campo multipart obrigatório é `file`. `folder_id` e `note_id` são opcionais. Sem eles, o arquivo fica na raiz e sem associação a nota. O nome enviado no multipart vira o nome do anexo; use nomes com letras/números, espaço, `. , _ - ( ) #` (até 200 caracteres).
+
+Defina a URL pública da instância e o mesmo token configurado no servidor:
+
+```sh
+export NOTAS_URL='https://notas.exemplo.com'
+export NOTES_TOKEN='seu-token'
+FILE='./relatorio.pdf'
+```
+
+Não inclua `/api` em `NOTAS_URL`.
+
+## `curl` — caminho recomendado
+
+O `curl` monta o multipart e envia o arquivo sem precisar construir o corpo à mão:
+
+```sh
+curl --fail-with-body --show-error --silent \
+ -X POST "$NOTAS_URL/api/files/upload" \
+ -H "Authorization: Bearer $NOTES_TOKEN" \
+ -F "file=@$FILE"
+```
+
+Para colocar o arquivo em uma pasta e associá-lo a uma nota, envie também os IDs:
+
+```sh
+curl --fail-with-body --show-error --silent \
+ -X POST "$NOTAS_URL/api/files/upload" \
+ -H "Authorization: Bearer $NOTES_TOKEN" \
+ -F "file=@$FILE" \
+ -F "folder_id=$FOLDER_ID" \
+ -F "note_id=$NOTE_ID"
+```
+
+`folder_id` e `note_id` precisam existir na instância. Obtenha os IDs existentes com `GET /api/files/tree` e `GET /api/notes`, autenticando com o mesmo Bearer token.
+
+## `wget` — GNU Wget2
+
+O GNU Wget não possui equivalente a `curl -F`. Com Wget2, monte o multipart temporário e envie com `--method=POST --body-file`; `--content-on-error` preserva respostas JSON de erro. O exemplo também imprime o JSON de sucesso:
+
+```sh
+set -eu
+boundary="----notas-upload-$(date +%s)-$$"
+filename=${FILE##*/}
+body=$(mktemp)
+trap 'rm -f "$body"' EXIT HUP INT TERM
+
+{
+ if [ -n "${FOLDER_ID:-}" ]; then
+ printf -- '--%s\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n%s\r\n' \
+ "$boundary" "$FOLDER_ID"
+ fi
+ if [ -n "${NOTE_ID:-}" ]; then
+ printf -- '--%s\r\nContent-Disposition: form-data; name="note_id"\r\n\r\n%s\r\n' \
+ "$boundary" "$NOTE_ID"
+ fi
+ printf -- '--%s\r\n' "$boundary"
+ printf 'Content-Disposition: form-data; name="file"; filename="%s"\r\n' "$filename"
+ printf 'Content-Type: application/octet-stream\r\n\r\n'
+ cat -- "$FILE"
+ printf '\r\n--%s--\r\n' "$boundary"
+} > "$body"
+
+wget --quiet --content-on-error --method=POST \
+ --header="Authorization: Bearer $NOTES_TOKEN" \
+ --header="Content-Type: multipart/form-data; boundary=$boundary" \
+ --body-file="$body" -O - "$NOTAS_URL/api/files/upload"
+```
+
+`FOLDER_ID` e `NOTE_ID` são opcionais. O exemplo requer GNU Wget2 (com `--body-file`, `--method` e `--content-on-error`); para GNU Wget 1.x, prefira o exemplo `curl` acima.
+
+## Resposta e erros
+
+Sucesso retorna **201 Created** e um JSON com o identificador, nome, tamanho, MIME, SHA-256 e URL do arquivo:
+
+```json
+{
+ "id": "a1b2c3d4e5f6",
+ "name": "relatorio.pdf",
+ "folder": "",
+ "note": "",
+ "size": 12345,
+ "mime": "application/pdf",
+ "sha256": "…",
+ "created_at": "2026-11-06T12:00:00Z",
+ "url": "/api/files/static/a1b2c3d4e5f6"
+}
+```
+
+- **401** — token ausente ou inválido; envie `Authorization: Bearer $NOTES_TOKEN`.
+- **400** — multipart inválido, campo `file` ausente, nome inválido, pasta/nota inexistente.
+- **413** — arquivo acima de **64 MiB**, corpo multipart acima do limite ou quota total de anexos (**2 GiB por instância**) excedida.
+- **201** — upload concluído. Guarde `id` ou `url` para referenciar o anexo.
+
+Uploads precisam passar por HTTPS em produção. Evite colocar o token literalmente no histórico do shell ou em scripts versionados; use variável de ambiente ou um gerenciador de segredos.
\ No newline at end of file
diff --git a/internal/api/files.go b/internal/api/files.go
index a7f62f3..b6d8199 100644
--- a/internal/api/files.go
+++ b/internal/api/files.go
@@ -1,6 +1,7 @@
package api
import (
+ "errors"
"net/http"
"github.com/lgcosta/notas-online/internal/store"
@@ -19,7 +20,12 @@ const maxUploadBody = store.MaxFileBytes + 64<<10
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBody)
if err := r.ParseMultipartForm(8 << 20); err != nil {
- writeError(w, http.StatusBadRequest, "multipart inválido ou excede limite: "+err.Error())
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ writeError(w, http.StatusRequestEntityTooLarge, "upload excede o limite de 64 MiB")
+ } else {
+ writeError(w, http.StatusBadRequest, "multipart inválido")
+ }
return
}
defer r.MultipartForm.RemoveAll()
diff --git a/internal/api/files_test.go b/internal/api/files_test.go
index 0a7e42f..1018911 100644
--- a/internal/api/files_test.go
+++ b/internal/api/files_test.go
@@ -252,7 +252,36 @@ func TestUploadRejectsOversizeBody(t *testing.T) {
t.Fatal(err)
}
resp.Body.Close()
- if resp.StatusCode != http.StatusBadRequest {
- t.Fatalf("esperava 400 para corpo acima do cap, obtive %s", resp.Status)
+ if resp.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("esperava 413 para corpo acima do cap, obtive %s", resp.Status)
+ }
+}
+
+func TestUploadRejectsFileOver64MiB(t *testing.T) {
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ fw, err := w.CreateFormFile("file", "big.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = fw.Write(bytes.Repeat([]byte("A"), store.MaxFileBytes+1))
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ srv := newTestServer(t)
+ req, err := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+testToken)
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("esperava 413 para arquivo >64 MiB, obtive %s", resp.Status)
}
}
diff --git a/internal/api/server.go b/internal/api/server.go
index c275759..0ec7f85 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -292,7 +292,11 @@ func mapStoreErrorFor(w http.ResponseWriter, err error, resource string) {
case errors.Is(err, store.ErrInvalid):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLarge):
- writeError(w, http.StatusRequestEntityTooLarge, "conteúdo excede 2 MiB")
+ if resource == "arquivo" {
+ writeError(w, http.StatusRequestEntityTooLarge, "arquivo excede 64 MiB ou a quota total de anexos excede 2 GiB")
+ } else {
+ writeError(w, http.StatusRequestEntityTooLarge, "conteúdo excede 2 MiB")
+ }
case errors.Is(err, store.ErrUnchanged):
writeError(w, http.StatusConflict, "o conteúdo dessa revisão já está ativo")
default: