Result box: color-code by status + friendly error messages from error codes (#105)
- Backend create/can validation paths emit machine-readable error codes (slug_taken, slug_invalid, content_empty, content_too_large, expiry_invalid, rate_limited, ...) alongside the human message - new.html JS maps codes to plain-language guidance with generic fallback - Result card colored via --ok/--err/--warn left border (result-ok/err/warn) - docs/API.md error section documents the code field - Tests assert the code on every validation path
This commit is contained in:
+15
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+32
-3
@@ -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{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
|
||||
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', '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('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
|
||||
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
|
||||
const copyBtn = document.getElementById('result-copy');
|
||||
copyBtn.addEventListener('click', () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user