From 61018a90b58700d356c96b2b8bded1a26cfb8a70 Mon Sep 17 00:00:00 2001 From: lgcosta Date: Fri, 6 Nov 2026 08:35:19 -0300 Subject: [PATCH] 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. --- README.md | 3 +- UPLOAD-CLI.md | 105 +++++++++++++++++++++++++++++++++++++ internal/api/files.go | 8 ++- internal/api/files_test.go | 33 +++++++++++- internal/api/server.go | 6 ++- 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 UPLOAD-CLI.md 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 `![…](url)` / `![]()` / 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: