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 {