#!/usr/bin/env python3 """Echo Task Board API — thin HTTP router. All endpoint logic lives in `dashboard/handlers/*.py`. This file is responsible only for URL dispatch, CORS, JSON response helpers, and server bootstrap. """ import json import os import sys from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import quote as _urlquote, parse_qs, urlparse # Make dashboard/ importable for the handler submodules (constants, # habits_helpers, handlers.*). Tests rely on this as well. _DASH = Path(__file__).parent if str(_DASH) not in sys.path: sys.path.insert(0, str(_DASH)) from constants import ( # noqa: E402 re-exported for tests ALLOWED_WORKSPACES, BASE_DIR, ECHO_CORE_DIR, ECHO_LOG_FILE, ECHO_SESSIONS_FILE, ECO_SERVICES, GIT_WORKSPACE, GITEA_ORG, GITEA_TOKEN, GITEA_URL, HABITS_FILE, KANBAN_DIR, NOTES_DIR, TOOLS_DIR, VENV_PYTHON, WORKSPACE_DIR, ) from handlers.auth import AuthHandlers # noqa: E402 from handlers.cron import CronHandlers # noqa: E402 from handlers.eco import EcoHandlers # noqa: E402 from handlers.files import FilesHandlers # noqa: E402 from handlers.git import GitHandlers # noqa: E402 from handlers.habits import HabitsHandlers # noqa: E402 from handlers.pdf import PDFHandlers # noqa: E402 from handlers.projects import ProjectsHandlers # noqa: E402 from handlers.ralph import RalphHandlers # noqa: E402 from handlers.workspace import WorkspaceHandlers # noqa: E402 from handlers.youtube import YoutubeHandlers # noqa: E402 # Shared navigation injected into every served .html via marker. NAV_HTML = '''
''' class TaskBoardHandler( AuthHandlers, ProjectsHandlers, GitHandlers, HabitsHandlers, EcoHandlers, FilesHandlers, PDFHandlers, YoutubeHandlers, WorkspaceHandlers, RalphHandlers, CronHandlers, SimpleHTTPRequestHandler, ): """HTTP request handler — dispatches to handler-mixin methods.""" # ── shared utilities ──────────────────────────────────────── def _read_post_json(self): """Read a JSON body from the POST request.""" content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length).decode('utf-8') return json.loads(post_data) def send_json(self, data, code=200): self.send_response(code) self.send_header('Content-Type', 'application/json') self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') self.send_header('Pragma', 'no-cache') self.send_header('Expires', '0') self.end_headers() self.wfile.write(json.dumps(data).encode()) def do_OPTIONS(self): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.end_headers() # ── dispatch ──────────────────────────────────────────────── def do_GET(self): from datetime import datetime as _dt import os # Static assets — served directly from dashboard/static/. Handles the # case where the URL is hit with the /echo/ prefix intact (e.g. direct # localhost curl); when behind the reverse proxy that strips /echo/, # the request falls through to SimpleHTTPRequestHandler which serves # cwd/static/ naturally (cwd is set to KANBAN_DIR/dashboard). if self.path.startswith('/echo/static/'): rel = self.path[len('/echo/static/'):].split('?', 1)[0] file_path = os.path.join(os.path.dirname(__file__), 'static', rel) if os.path.isfile(file_path): ext = os.path.splitext(rel)[1].lstrip('.').lower() ctype = { 'css': 'text/css', 'woff2': 'font/woff2', 'woff': 'font/woff', 'js': 'application/javascript', 'svg': 'image/svg+xml', 'png': 'image/png', }.get(ext, 'application/octet-stream') with open(file_path, 'rb') as f: data = f.read() self.send_response(200) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(data))) self.send_header('Cache-Control', 'public, max-age=86400') self.end_headers() self.wfile.write(data) else: self.send_error(404) return if self.path == '/api/status': self.send_json({'status': 'ok', 'time': _dt.now().isoformat()}) elif self.path == '/api/git' or self.path.startswith('/api/git?'): self.handle_git_status() elif self.path == '/api/cron' or self.path.startswith('/api/cron?'): self.handle_cron_status() elif self.path == '/api/habits': self.handle_habits_get() elif self.path.startswith('/api/files'): self.handle_files_get() elif self.path.startswith('/api/diff'): self.handle_git_diff() elif self.path == '/api/workspace' or self.path.startswith('/api/workspace?'): self.handle_workspace_list() elif self.path.startswith('/api/workspace/git/diff'): self.handle_workspace_git_diff() elif self.path.startswith('/api/workspace/logs'): self.handle_workspace_logs() elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'): self.handle_eco_status() elif self.path == '/api/eco/sessions' or self.path.startswith('/api/eco/sessions?'): self.handle_eco_sessions() elif self.path.startswith('/api/eco/sessions/content'): self.handle_eco_session_content() elif self.path.startswith('/api/eco/logs'): self.handle_eco_logs() elif self.path == '/api/eco/doctor': self.handle_eco_doctor() elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'): self.handle_ralph_status() elif self.path == '/api/ralph/usage' or self.path.startswith('/api/ralph/usage?'): self.handle_ralph_usage() elif self.path == '/api/ralph/stream' or self.path.startswith('/api/ralph/stream?'): self.handle_ralph_stream() elif self.path.startswith('/api/ralph/'): # /api/ralph//log or /api/ralph//prd parts = self.path.split('?', 1)[0].split('/') # parts: ['', 'api', 'ralph', '', ''] if len(parts) >= 5: slug = parts[3] action = parts[4] if action == 'log': self.handle_ralph_log(slug) elif action == 'prd': self.handle_ralph_prd(slug) else: self.send_error(404) else: self.send_error(404) elif self.path == '/api/projects' or self.path.startswith('/api/projects?'): self.handle_unified_status() elif self.path == '/api/projects/signature' or self.path.startswith('/api/projects/signature?'): self.handle_unified_signature() elif self.path == '/api/projects/stream' or self.path.startswith('/api/projects/stream?'): self.handle_projects_stream() elif self.path.startswith('/api/projects/'): # /api/projects//plan/(state|transcript) parts = self.path.split('?', 1)[0].split('/') # parts: ['', 'api', 'projects', '', 'plan', ''] if len(parts) >= 6 and parts[4] == 'plan': slug = parts[3] action = parts[5] if action == 'state': self.handle_plan_state(slug) elif action == 'transcript': self.handle_plan_transcript(slug) else: self.send_error(404) else: self.send_error(404) elif self.path in ('/', '/echo', '/echo/'): self.send_response(302) self.send_header('Location', '/echo/index.html') self.send_header('Content-Length', '0') self.end_headers() return elif self.path in ('/echo/login', '/login') or \ self.path.startswith(('/echo/login?', '/login?')): # If already logged in, redirect to next (or workspace); otherwise serve login.html. if self._check_dashboard_cookie(): qs = parse_qs(urlparse(self.path).query) next_vals = qs.get('next', []) nxt = next_vals[0] if next_vals else '' # Proxy strips /echo/ before Python, so nxt is e.g. /workspace.html. # Re-add the prefix so the browser lands on the right public URL. if nxt and nxt.startswith('/') and '://' not in nxt: dest = '/echo' + nxt else: dest = '/echo/workspace.html' self.send_response(302) self.send_header('Location', dest) self.send_header('Content-Length', '0') self.end_headers() return login_html = KANBAN_DIR / 'login.html' if login_html.is_file(): body = login_html.read_text('utf-8').replace('', NAV_HTML).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-cache') self.end_headers() self.wfile.write(body) else: # Lane B2 hasn't shipped yet — return 503 with a hint. self.send_error(503, 'login.html not yet available') elif self.path == '/ralph.html' or self.path.startswith('/ralph.html?'): # Legacy redirect — Ralph dashboard merged into workspace.html (Lane D1). self.send_response(302) self.send_header('Location', '/echo/workspace.html') self.send_header('Content-Length', '0') self.end_headers() return elif self.path.startswith('/api/'): self.send_error(404) else: # Inject shared nav into served HTML pages via marker. rel = self.path.lstrip('/').split('?')[0] if rel.endswith('.html'): try: fpath = (KANBAN_DIR / rel).resolve() fpath.relative_to(KANBAN_DIR.resolve()) except (ValueError, OSError): self.send_error(403) return if fpath.is_file(): if fpath.name != 'login.html' and not self._check_dashboard_cookie(): self.send_response(302) next_param = _urlquote(self.path, safe='/?=&#') self.send_header('Location', f'/echo/login?next={next_param}') self.send_header('Content-Length', '0') self.end_headers() return html = fpath.read_text('utf-8').replace('', NAV_HTML) body = html.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-cache') self.end_headers() self.wfile.write(body) return super().do_GET() # POSTs that bypass the auth middleware (login itself can't require a cookie). UNPROTECTED_POSTS = frozenset({'/api/auth/login'}) def do_POST(self): # ── Auth middleware ──────────────────────────────────────── # Only protect /api/* POSTs for now — older endpoints predate auth and # we want a single, well-defined gate. Static asset POSTs (none today) # would also fall through. path_only = self.path.split('?', 1)[0] if path_only.startswith('/api/') and path_only not in self.UNPROTECTED_POSTS: if not self._check_dashboard_cookie(): body = b'{"error":"Unauthorized"}' self.send_response(401) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass return # CSRF: require Origin (or Referer) to be on the allowlist. origin = self.headers.get('Origin', '') or '' referer = self.headers.get('Referer', '') or '' allowed = ['http://127.0.0.1:8088', 'http://localhost:8088'] dh = os.environ.get('DASHBOARD_HOST', '').strip() if dh: allowed.append(dh) check = origin or referer if check and not any(check.startswith(a) for a in allowed): body = b'{"error":"CSRF"}' self.send_response(403) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass return if self.path == '/api/youtube': self.handle_youtube() elif self.path == '/api/files': self.handle_files_post() elif self.path == '/api/refresh-index': self.handle_refresh_index() elif self.path == '/api/pdf': self.handle_pdf_post() elif self.path == '/api/habits': self.handle_habits_post() elif self.path.startswith('/api/habits/') and self.path.endswith('/check'): self.handle_habits_check() elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'): self.handle_habits_skip() elif self.path == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': self.handle_workspace_stop() elif self.path == '/api/workspace/git/commit': self.handle_workspace_git_commit() elif self.path == '/api/workspace/git/push': self.handle_workspace_git_push() elif self.path == '/api/workspace/delete': self.handle_workspace_delete() elif self.path == '/api/eco/restart': self.handle_eco_restart() elif self.path == '/api/eco/stop': self.handle_eco_stop() elif self.path == '/api/eco/sessions/clear': self.handle_eco_sessions_clear() elif self.path == '/api/eco/git-commit': self.handle_eco_git_commit() elif self.path == '/api/eco/restart-taskboard': self.handle_eco_restart_taskboard() elif self.path.startswith('/api/ralph/'): # /api/ralph//{stop,rollback} parts = self.path.split('?', 1)[0].split('/') if len(parts) >= 5: slug = parts[3] action = parts[4] if action == 'stop': self.handle_ralph_stop(slug) elif action == 'rollback': self.handle_ralph_rollback(slug) else: self.send_error(404) else: self.send_error(404) elif self.path == '/api/auth/login': self.handle_login() elif self.path == '/api/auth/logout': self.handle_logout() elif self.path == '/api/projects/propose': self.handle_propose() elif self.path == '/api/projects/approve': self.handle_approve() elif self.path == '/api/projects/unapprove': self.handle_unapprove() elif self.path == '/api/projects/cancel': self.handle_cancel() elif self.path.startswith('/api/projects/'): # /api/projects//plan/(start|respond|finalize|cancel|advance) parts = self.path.split('?', 1)[0].split('/') # parts: ['', 'api', 'projects', '', 'plan', ''] if len(parts) >= 6 and parts[4] == 'plan': slug = parts[3] action = parts[5] if action == 'start': self.handle_plan_start(slug) elif action == 'respond': self.handle_plan_respond(slug) elif action == 'finalize': self.handle_plan_finalize(slug) elif action == 'cancel': self.handle_plan_cancel_planning(slug) elif action == 'advance': self.handle_plan_advance(slug) else: self.send_error(404) else: self.send_error(404) else: self.send_error(404) def do_PUT(self): if self.path.startswith('/api/habits/'): self.handle_habits_put() else: self.send_error(404) def do_DELETE(self): if self.path.startswith('/api/habits/') and '/check' in self.path: self.handle_habits_uncheck() elif self.path.startswith('/api/habits/'): self.handle_habits_delete() else: self.send_error(404) if __name__ == '__main__': import os port = 8088 os.chdir(KANBAN_DIR) print(f"Starting Echo Task Board API on port {port}") # ThreadingHTTPServer permite SSE long-lived (/api/ralph/stream) fără să # blocheze celelalte request-uri. httpd = ThreadingHTTPServer(('0.0.0.0', port), TaskBoardHandler) httpd.daemon_threads = True httpd.serve_forever()