Files
echo-core/dashboard/api.py
Marius Mutu 38259f3cfd fix(auth): redirect to original URL after login
Pass current path as ?next= when bouncing unauthenticated requests
to /echo/login; after successful auth, JS reads and validates the
param (must start with /echo/, not /echo/login) before redirecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:38:27 +00:00

457 lines
20 KiB
Python

#!/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
# 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 <!--NAV--> marker.
NAV_HTML = '''<header class="header">
<a href="/echo/index.html" class="logo">
<i data-lucide="circle-dot"></i>
Echo
</a>
<nav class="nav">
<a href="/echo/index.html" class="nav-item" data-page="index">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</a>
<a href="/echo/workspace.html" class="nav-item" data-page="workspace">
<i data-lucide="code"></i>
<span>Workspace</span>
</a>
<a href="/echo/notes.html" class="nav-item" data-page="notes">
<i data-lucide="file-text"></i>
<span>KB</span>
</a>
<a href="/echo/habits.html" class="nav-item" data-page="habits">
<i data-lucide="dumbbell"></i>
<span>Habits</span>
</a>
<a href="/echo/files.html" class="nav-item" data-page="files">
<i data-lucide="folder"></i>
<span>Files</span>
</a>
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
<i data-lucide="sun" id="themeIcon"></i>
</button>
</nav>
</header>
<script>
(function(){
var path = window.location.pathname;
var m = path.match(/([^\\/]+?)(?:\\.html)?$/);
var page = m ? m[1] : 'index';
if (!page || page === 'echo') page = 'index';
var item = document.querySelector('.nav-item[data-page="' + page + '"]');
if (item) item.classList.add('active');
})();
</script>'''
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/<slug>/log or /api/ralph/<slug>/prd
parts = self.path.split('?', 1)[0].split('/')
# parts: ['', 'api', 'ralph', '<slug>', '<action>']
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/<slug>/plan/(state|transcript)
parts = self.path.split('?', 1)[0].split('/')
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
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 workspace; otherwise serve
# login.html (created in Lane B2).
if self._check_dashboard_cookie():
self.send_response(302)
self.send_header('Location', '/echo/workspace.html')
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-->', 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 <!--NAV--> 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-->', 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/<slug>/{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/<slug>/plan/(start|respond|finalize|cancel|advance)
parts = self.path.split('?', 1)[0].split('/')
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
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()