Merges workspace.html + ralph.html into a single unified project hub with: - Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict) - 9-state project badge system (running-ralph/manual, planning, approved, pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix - SSE realtime + polling fallback, version-based optimistic concurrency (If-Match) - Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume) - Propose modal (Variant B: inline Plan-with-Echo checkbox) - 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar) - Inter font self-hosted + shared tokens.css design system + DESIGN.md - src/jsonlock.py (flock helper, sidecar .lock for stable inode) - src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh) - 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28 - No emoji anywhere (enforced by test_dashboard_no_emoji.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
55 lines
1.8 KiB
Python
55 lines
1.8 KiB
Python
"""Shared validation helpers for dashboard handlers."""
|
|
import json
|
|
import re
|
|
from http.server import BaseHTTPRequestHandler
|
|
|
|
_SLUG_RE = re.compile(r'^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$')
|
|
|
|
|
|
def validate_slug(slug: str) -> str | None:
|
|
"""Returns error message or None if valid."""
|
|
if not slug:
|
|
return "slug required"
|
|
if not _SLUG_RE.match(slug):
|
|
return "slug must be 3-40 chars, lowercase alphanumeric + hyphens/underscores"
|
|
return None
|
|
|
|
|
|
def validate_description(desc: str) -> str | None:
|
|
"""Returns error message or None if valid. Min 10 chars, max 500."""
|
|
if not desc or len(desc.strip()) < 10:
|
|
return "description must be at least 10 characters"
|
|
if len(desc) > 500:
|
|
return "description must be at most 500 characters"
|
|
return None
|
|
|
|
|
|
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict | None:
|
|
"""Parse JSON body from request. Returns None on failure (sends 400)."""
|
|
try:
|
|
length = int(handler.headers.get('Content-Length', '0') or '0')
|
|
except (TypeError, ValueError):
|
|
length = 0
|
|
|
|
def _send_error(msg: str) -> None:
|
|
sender = getattr(handler, 'send_json', None)
|
|
if callable(sender):
|
|
sender({'error': msg}, 400)
|
|
return
|
|
body = json.dumps({'error': msg}).encode()
|
|
handler.send_response(400)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.send_header('Content-Length', str(len(body)))
|
|
handler.end_headers()
|
|
handler.wfile.write(body)
|
|
|
|
if length <= 0:
|
|
_send_error('empty body')
|
|
return None
|
|
try:
|
|
raw = handler.rfile.read(length)
|
|
return json.loads(raw.decode('utf-8'))
|
|
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
|
_send_error('invalid JSON body')
|
|
return None
|