diff --git a/docs/API.md b/docs/API.md index efe29d7..a5aaa85 100644 --- a/docs/API.md +++ b/docs/API.md @@ -32,9 +32,21 @@ curl -X POST http://localhost:8080/api/pastes \ - Response includes `id`, `url`, `raw_url`, `api_url`, `expires_at`, `created_at`, and a one-time `deletion_token`. -Errors: `400` invalid body/content too large/duplicate slug, `401` password -required, `404` paste expired/burned/gone, `413` content exceeds max bytes, -`429` rate limited. +Errors return JSON with a human-readable `error` message plus a +machine-readable `code` the web UI maps to plain-language guidance (#105): + +| Code | Status | Meaning | +|---|---|---| +| `content_empty` | 400 | content is required | +| `slug_invalid` | 400/409 | custom slug malformed | +| `slug_taken` | 409 | custom slug already in use | +| `slug_reserved` | 409 | custom slug is reserved | +| `expiry_invalid` | 400 | expires_in out of 1 minute – 1 year range | +| `content_too_large` | 413 | content or body exceeds the size cap | +| `rate_limited` | 429 | too many requests; see `Retry-After` | + +Other statuses: `400` invalid body, `401` password required, `404` paste +expired/burned/gone. Unknown codes should be treated as a generic failure. ## Get paste diff --git a/internal/api/cans.go b/internal/api/cans.go index 836561c..9107bc3 100644 --- a/internal/api/cans.go +++ b/internal/api/cans.go @@ -28,7 +28,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { visibility = "public" } if visibility != "public" && visibility != "unlisted" { - writeErr(w, 400, "visibility must be public or unlisted") + writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted") return } expiresIn := r.FormValue("expires_in") @@ -40,13 +40,13 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { if expiresIn != "" { d, err := time.ParseDuration(expiresIn) if err != nil { - writeErr(w, 400, "invalid expires_in") + writeErrCode(w, 400, "expiry_invalid", "invalid expires_in") return } // #60/#48: clamp at the API boundary like the pastes API does - // reject zero/negative and durations past the 1-year UI cap. if !store.ValidExpiry(d) { - writeErr(w, 400, "expires_in must be between 1 minute and 1 year") + writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year") return } t := now + int64(d.Seconds()) @@ -71,7 +71,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { if err != nil { switch err { case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug: - writeErr(w, 409, err.Error()) + writeErrCode(w, 409, createErrCode(err), err.Error()) default: writeErr(w, 500, "db error") } @@ -137,7 +137,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { if itemCount == 0 { a.store.DeleteCan(canID) - writeErr(w, 400, "can needs at least one item (files or json_items)") + writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)") return } diff --git a/internal/api/issue105_errorcode_test.go b/internal/api/issue105_errorcode_test.go new file mode 100644 index 0000000..670ff95 --- /dev/null +++ b/internal/api/issue105_errorcode_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// #105: every validation path on create returns a machine-readable `code` +// alongside the human `error` message, so the new-page JS can map known +// codes to plain-language guidance. + +func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) (status int, errMsg, code string) { + t.Helper() + var got struct { + Error string `json:"error"` + Code string `json:"code"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("bad json: %v (%s)", err, rec.Body.String()) + } + return rec.Code, got.Error, got.Code +} + +func postCreate(h http.Handler, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestErrorCodeContentEmpty(t *testing.T) { + h := testServer(t).routes() + _, _, code := decodeErr(t, postCreate(h, `{"content":" "}`)) + if code != "content_empty" { + t.Fatalf("code = %q, want content_empty", code) + } +} + +func TestErrorCodeContentTooLarge(t *testing.T) { + h := testServer(t).routes() + big := strings.Repeat("x", 6*1024*1024) // over the 5MB test cap + _, _, code := decodeErr(t, postCreate(h, `{"content":"`+big+`"}`)) + if code != "content_too_large" { + t.Fatalf("code = %q, want content_too_large", code) + } +} + +func TestErrorCodeSlugTaken(t *testing.T) { + h := testServer(t).routes() + if rec := postCreate(h, `{"content":"a","custom_slug":"taken-slug"}`); rec.Code != 201 { + t.Fatalf("seed create: %d %s", rec.Code, rec.Body.String()) + } + _, _, code := decodeErr(t, postCreate(h, `{"content":"b","custom_slug":"taken-slug"}`)) + if code != "slug_taken" { + t.Fatalf("code = %q, want slug_taken", code) + } +} + +func TestErrorCodeSlugInvalid(t *testing.T) { + h := testServer(t).routes() + _, _, code := decodeErr(t, postCreate(h, `{"content":"a","custom_slug":"bad slug!"}`)) + if code != "slug_invalid" { + t.Fatalf("code = %q, want slug_invalid", code) + } +} + +func TestErrorCodeExpiryInvalid(t *testing.T) { + h := testServer(t).routes() + _, _, code := decodeErr(t, postCreate(h, `{"content":"a","expires_in":"2s"}`)) + if code != "expiry_invalid" { + t.Fatalf("code = %q, want expiry_invalid", code) + } +} + +func TestErrorCodeRateLimited(t *testing.T) { + h := testServer(t).routes() + var code string + for i := 0; i < 20; i++ { + _, _, code = decodeErr(t, postCreate(h, `{"content":"a"}`)) + if code == "rate_limited" { + return + } + } + t.Fatalf("never got rate_limited, last code = %q", code) +} + +func TestErrorCodeCanValidation(t *testing.T) { + h := testServer(t).routes() + + post := func(fields map[string]string) *httptest.ResponseRecorder { + var b strings.Builder + for k, v := range fields { + b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n") + } + b.WriteString("--B--\r\n") + req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String())) + req.Header.Set("Content-Type", "multipart/form-data; boundary=B") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // bad visibility + if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" { + t.Fatalf("visibility code = %q, want invalid_visibility", code) + } + + // invalid expiry + if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" { + t.Fatalf("expiry code = %q, want expiry_invalid", code) + } + + // slug taken on can create + if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 { + t.Fatalf("seed create: %d", rec.Code) + } + if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" { + t.Fatalf("can slug code = %q, want slug_taken", code) + } +} diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go index 5533831..7c44bd6 100644 --- a/internal/api/ratelimit.go +++ b/internal/api/ratelimit.go @@ -113,7 +113,7 @@ func rateLimitAdmin(r *http.Request) bool { // writeRateLimited responds 429 with Retry-After based on refill rate. func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs)) - writeErr(w, 429, "rate limit exceeded") + writeErrCode(w, 429, "rate_limited", "rate limit exceeded") } // setRateLimitHeaders sets informational X-RateLimit headers for create/guess. diff --git a/internal/api/server.go b/internal/api/server.go index 7c3b8f8..a1a2ae4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "html/template" "net/http" @@ -51,6 +52,30 @@ func writeErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } +// writeErrCode emits a JSON error with a machine-readable code (#105): the +// new-page JS maps known codes to plain-language messages, API consumers get +// the stable `code` field alongside the human `error` text. +func writeErrCode(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]string{"error": msg, "code": code}) +} + +// createErrCode maps a store.CreatePaste/CreateCan error to its UI error +// code (#105). +func createErrCode(err error) string { + switch { + case errors.Is(err, store.ErrSlugTaken): + return "slug_taken" + case errors.Is(err, store.ErrReservedSlug): + return "slug_reserved" + case errors.Is(err, store.ErrInvalidSlug): + return "slug_invalid" + case strings.Contains(err.Error(), "expires_in"): + return "expiry_invalid" + default: + return "invalid_request" + } +} + // Routes returns the HTTP handler for the server. func (a *apiServer) Routes() http.Handler { return a.routes() @@ -158,14 +183,18 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { var p store.Paste if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader - writeBodyTooLarge(w) + writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large") return } writeErr(w, 400, "invalid json body") return } if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 { - writeErr(w, status, msg) + if status == http.StatusRequestEntityTooLarge { + writeErrCode(w, status, "content_too_large", msg) + } else { + writeErrCode(w, status, "content_empty", msg) + } return } // #86: bound free-form metadata at create time @@ -203,7 +232,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { p.ViewerID = currentViewerID(r) created, err := a.store.CreatePaste(&p) if err != nil { - writeErr(w, 400, err.Error()) + writeErrCode(w, 400, createErrCode(err), err.Error()) return } writeJSON(w, 201, map[string]any{ diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d37f050..67d0cf8 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -296,6 +296,13 @@ td a.slug:hover { color: var(--accent); } .toast.success { border-color: var(--ok); color: var(--ok); } .toast.error { border-color: var(--err); color: var(--err); } +/* result box color-coding (#105): colored left border + text tint per status */ +.result-ok, .result-err, .result-warn { border-left: 4px solid transparent; padding-left: 10px; } +.result-ok { border-left-color: var(--ok); color: var(--ok); } +.result-err { border-left-color: var(--err); color: var(--err); } +.result-warn { border-left-color: var(--warn); color: var(--warn); } +.result-ok a { color: var(--ok); } + /* protection section rhythm (#20) */ .protect { display: flex; flex-direction: column; gap: 2px; } .protect .pw-row { padding: 2px 8px 4px; } diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 27fc631..529168b 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -171,10 +171,37 @@ $('can-add').addEventListener('click', addCanItem); let guessed = ''; // last auto-detected language, '' = user override -function showResult(html, isError) { +// #105: map backend machine-readable error codes to plain-language guidance. +// Unknown codes fall back to a generic message; the technical detail stays +// in the API response for API consumers. +const ERROR_MESSAGES = { + slug_taken: 'That Custom URL is already taken. Try another.', + slug_reserved: 'That Custom URL is reserved. Try another.', + slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.', + content_empty: 'Write or paste something first.', + content_too_large: 'This paste is too large. The limit is 5 MB.', + expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.', + rate_limited: 'Too many tries. Wait a minute and try again.', +}; +const GENERIC_ERROR = 'Something went wrong. Please try again.'; + +function friendlyError(data) { + return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR; +} + +// #105: color the result box by outcome — success (ok), error (err), +// warning (warn) — with a colored left border (CSS .result-ok/.result-err). +function setResultKind(kind) { + const card = $('result-card'); + card.classList.remove('result-ok', 'result-err', 'result-warn'); + if (kind) card.classList.add('result-' + kind); +} + +function showResult(html, kind) { $('result').innerHTML = html; - $('result').dataset.token = isError ? '' : ($('result').dataset.token || ''); + $('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || ''); $('result-card').style.display = 'block'; + setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err')); } function defaultFilename(lang) { const names = { @@ -249,12 +276,12 @@ async function create() { }); const data = await res.json(); if (!res.ok) { - showResult('Error: ' + (data.error || res.status), true); + showResult(friendlyError(data), 'err'); toast('Create failed', 'error'); return; } const url = location.origin + '/' + (data.custom_slug || data.id); - showResult('' + url + ' ', false); + showResult('' + url + ' ', 'ok'); $('result').dataset.token = data.deletion_token || ''; const copyBtn = document.getElementById('result-copy'); copyBtn.addEventListener('click', () => { @@ -312,12 +339,12 @@ async function createCan() { const res = await fetch('/api/pastes/can', {method: 'POST', body: fd}); const data = await res.json(); if (!res.ok) { - showResult('Error: ' + (data.error || res.status), true); + showResult(friendlyError(data), 'err'); toast('Can create failed', 'error'); return; } const url = location.origin + data.url; - showResult('' + url + ' ', false); + showResult('' + url + ' ', 'ok'); const copyBtn = document.getElementById('result-copy'); copyBtn.addEventListener('click', () => { try {