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`,
|
- Response includes `id`, `url`, `raw_url`, `api_url`, `expires_at`,
|
||||||
`created_at`, and a one-time `deletion_token`.
|
`created_at`, and a one-time `deletion_token`.
|
||||||
|
|
||||||
Errors: `400` invalid body/content too large/duplicate slug, `401` password
|
Errors return JSON with a human-readable `error` message plus a
|
||||||
required, `404` paste expired/burned/gone, `413` content exceeds max bytes,
|
machine-readable `code` the web UI maps to plain-language guidance (#105):
|
||||||
`429` rate limited.
|
|
||||||
|
| 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
|
## Get paste
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
visibility = "public"
|
visibility = "public"
|
||||||
}
|
}
|
||||||
if visibility != "public" && visibility != "unlisted" {
|
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
|
return
|
||||||
}
|
}
|
||||||
expiresIn := r.FormValue("expires_in")
|
expiresIn := r.FormValue("expires_in")
|
||||||
@@ -40,13 +40,13 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
if expiresIn != "" {
|
if expiresIn != "" {
|
||||||
d, err := time.ParseDuration(expiresIn)
|
d, err := time.ParseDuration(expiresIn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 400, "invalid expires_in")
|
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// #60/#48: clamp at the API boundary like the pastes API does -
|
// #60/#48: clamp at the API boundary like the pastes API does -
|
||||||
// reject zero/negative and durations past the 1-year UI cap.
|
// reject zero/negative and durations past the 1-year UI cap.
|
||||||
if !store.ValidExpiry(d) {
|
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
|
return
|
||||||
}
|
}
|
||||||
t := now + int64(d.Seconds())
|
t := now + int64(d.Seconds())
|
||||||
@@ -71,7 +71,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
switch err {
|
||||||
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
|
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
|
||||||
writeErr(w, 409, err.Error())
|
writeErrCode(w, 409, createErrCode(err), err.Error())
|
||||||
default:
|
default:
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if itemCount == 0 {
|
if itemCount == 0 {
|
||||||
a.store.DeleteCan(canID)
|
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
|
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.
|
// writeRateLimited responds 429 with Retry-After based on refill rate.
|
||||||
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
|
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.
|
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
|
||||||
|
|||||||
+32
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -51,6 +52,30 @@ func writeErr(w http.ResponseWriter, status int, msg string) {
|
|||||||
writeJSON(w, status, map[string]string{"error": msg})
|
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.
|
// Routes returns the HTTP handler for the server.
|
||||||
func (a *apiServer) Routes() http.Handler {
|
func (a *apiServer) Routes() http.Handler {
|
||||||
return a.routes()
|
return a.routes()
|
||||||
@@ -158,14 +183,18 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
|||||||
var p store.Paste
|
var p store.Paste
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
||||||
writeBodyTooLarge(w)
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeErr(w, 400, "invalid json body")
|
writeErr(w, 400, "invalid json body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
// #86: bound free-form metadata at create time
|
// #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)
|
p.ViewerID = currentViewerID(r)
|
||||||
created, err := a.store.CreatePaste(&p)
|
created, err := a.store.CreatePaste(&p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 400, err.Error())
|
writeErrCode(w, 400, createErrCode(err), err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 201, map[string]any{
|
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.success { border-color: var(--ok); color: var(--ok); }
|
||||||
.toast.error { border-color: var(--err); color: var(--err); }
|
.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) */
|
/* protection section rhythm (#20) */
|
||||||
.protect { display: flex; flex-direction: column; gap: 2px; }
|
.protect { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.protect .pw-row { padding: 2px 8px 4px; }
|
.protect .pw-row { padding: 2px 8px 4px; }
|
||||||
|
|||||||
@@ -171,10 +171,37 @@ $('can-add').addEventListener('click', addCanItem);
|
|||||||
|
|
||||||
let guessed = ''; // last auto-detected language, '' = user override
|
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').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';
|
$('result-card').style.display = 'block';
|
||||||
|
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
|
||||||
}
|
}
|
||||||
function defaultFilename(lang) {
|
function defaultFilename(lang) {
|
||||||
const names = {
|
const names = {
|
||||||
@@ -249,12 +276,12 @@ async function create() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showResult('Error: ' + (data.error || res.status), true);
|
showResult(friendlyError(data), 'err');
|
||||||
toast('Create failed', 'error');
|
toast('Create failed', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = location.origin + '/' + (data.custom_slug || data.id);
|
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 || '';
|
$('result').dataset.token = data.deletion_token || '';
|
||||||
const copyBtn = document.getElementById('result-copy');
|
const copyBtn = document.getElementById('result-copy');
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
@@ -312,12 +339,12 @@ async function createCan() {
|
|||||||
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
|
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showResult('Error: ' + (data.error || res.status), true);
|
showResult(friendlyError(data), 'err');
|
||||||
toast('Can create failed', 'error');
|
toast('Can create failed', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = location.origin + data.url;
|
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');
|
const copyBtn = document.getElementById('result-copy');
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user