Deploy MasseurBoost Endpoint API
Browse files- Dockerfile +5 -18
- README.md +6 -8
- __pycache__/app.cpython-313.pyc +0 -0
- app.py +149 -1085
Dockerfile
CHANGED
|
@@ -1,27 +1,14 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
-
ENV PYTHONUNBUFFERED=1
|
| 5 |
-
ENV PORT=7860
|
| 6 |
-
|
| 7 |
WORKDIR /app
|
| 8 |
|
| 9 |
-
RUN
|
| 10 |
-
bash \
|
| 11 |
-
coreutils \
|
| 12 |
-
findutils \
|
| 13 |
-
grep \
|
| 14 |
-
sed \
|
| 15 |
-
git \
|
| 16 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
-
|
| 18 |
-
COPY requirements.txt /app/requirements.txt
|
| 19 |
-
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 20 |
|
| 21 |
-
COPY
|
| 22 |
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
EXPOSE 7860
|
| 26 |
|
| 27 |
-
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
RUN pip install --no-cache-dir fastapi uvicorn[standard] jinja2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
COPY . /app
|
| 8 |
|
| 9 |
+
# Create data directory for bundled DB
|
| 10 |
+
RUN mkdir -p /data
|
| 11 |
|
| 12 |
EXPOSE 7860
|
| 13 |
|
| 14 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: pink
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
+
title: MasseurBoost Endpoint
|
| 2 |
+
emoji: 💆
|
| 3 |
+
colorFrom: violet
|
| 4 |
+
colorTo: fuchsia
|
|
|
|
| 5 |
sdk: docker
|
| 6 |
+
app_port: 7860
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
|
|
|
|
|
__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (7.57 kB). View file
|
|
|
app.py
CHANGED
|
@@ -1,1106 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
import json
|
| 4 |
-
import uuid
|
| 5 |
-
import time
|
| 6 |
-
import html
|
| 7 |
-
import socket
|
| 8 |
-
import hashlib
|
| 9 |
import sqlite3
|
| 10 |
-
import asyncio
|
| 11 |
-
import shlex
|
| 12 |
-
import ipaddress
|
| 13 |
from pathlib import Path
|
| 14 |
-
from
|
| 15 |
-
from
|
| 16 |
-
from
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
BLOCKED_TOKENS = [
|
| 67 |
-
"rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot",
|
| 68 |
-
"passwd", "useradd", "adduser", "su ", "sudo ", "chown /", "chmod 777 /",
|
| 69 |
-
"nc ", "ncat ", "telnet ", "ssh ", "scp ", "rsync ",
|
| 70 |
-
"python -m http.server", "uvicorn ", "gunicorn ", "nohup ", "& disown",
|
| 71 |
-
"/etc/passwd", "/etc/shadow", "/root", "/proc/self/environ",
|
| 72 |
-
"apt-get", "apk add", "yum install", "dnf install",
|
| 73 |
-
]
|
| 74 |
-
|
| 75 |
-
ALLOWED_COMMAND_PREFIXES = [
|
| 76 |
-
"pwd", "ls", "cat", "head", "tail", "wc", "find", "grep", "sed",
|
| 77 |
-
"python", "python3", "pytest", "git status", "git diff", "git log",
|
| 78 |
-
"tree", "du", "df", "date", "whoami", "echo", "touch", "mkdir",
|
| 79 |
-
"cp", "mv", "sha256sum", "sort", "uniq", "cut", "tr", "printf", "uname",
|
| 80 |
-
"node", "npm --version", "npx --yes vercel", "vercel --version"
|
| 81 |
-
]
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def now_iso() -> str:
|
| 85 |
-
return datetime.now(timezone.utc).isoformat()
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def sha256_bytes(raw: bytes) -> str:
|
| 89 |
-
return hashlib.sha256(raw).hexdigest()
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def sha256_json(payload: Any) -> str:
|
| 93 |
-
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
| 94 |
-
return sha256_bytes(raw)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def safe_name(value: str, default: str = "item") -> str:
|
| 98 |
-
value = (value or default).strip()
|
| 99 |
-
value = re.sub(r"[^a-zA-Z0-9._-]+", "-", value)
|
| 100 |
-
value = value.strip(".-")
|
| 101 |
-
return value[:120] or default
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def require_admin(x_admin_token: Optional[str]) -> None:
|
| 105 |
-
if not ADMIN_TOKEN:
|
| 106 |
-
raise HTTPException(status_code=503, detail="ADMIN_TOKEN is not configured")
|
| 107 |
-
if x_admin_token != ADMIN_TOKEN:
|
| 108 |
-
raise HTTPException(status_code=401, detail="Admin token required")
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def workspace_path(workspace: str, relative_path: str = ".") -> Path:
|
| 112 |
-
ws = safe_name(workspace, "default")
|
| 113 |
-
base = (WORKSPACE_ROOT / ws).resolve()
|
| 114 |
-
base.mkdir(parents=True, exist_ok=True)
|
| 115 |
-
target = (base / relative_path).resolve()
|
| 116 |
-
if not str(target).startswith(str(base)):
|
| 117 |
-
raise HTTPException(status_code=400, detail="Path escapes workspace")
|
| 118 |
-
return target
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def artifact_path(relative_path: str) -> Path:
|
| 122 |
-
safe = relative_path.replace("\\", "/").lstrip("/")
|
| 123 |
-
target = (ARTIFACT_ROOT / safe).resolve()
|
| 124 |
-
if not str(target).startswith(str(ARTIFACT_ROOT)):
|
| 125 |
-
raise HTTPException(status_code=400, detail="Path escapes artifact root")
|
| 126 |
-
target.parent.mkdir(parents=True, exist_ok=True)
|
| 127 |
-
return target
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def validate_command(command: str) -> None:
|
| 131 |
-
lowered = command.lower()
|
| 132 |
-
for token in BLOCKED_TOKENS:
|
| 133 |
-
if token.lower() in lowered:
|
| 134 |
-
raise HTTPException(status_code=400, detail=f"Blocked command token: {token}")
|
| 135 |
-
|
| 136 |
-
lines = [ln.strip() for ln in command.strip().splitlines() if ln.strip()]
|
| 137 |
-
if not lines:
|
| 138 |
-
raise HTTPException(status_code=400, detail="Empty command")
|
| 139 |
-
for line in lines:
|
| 140 |
-
if not any(line == p or line.startswith(p + " ") for p in ALLOWED_COMMAND_PREFIXES):
|
| 141 |
-
raise HTTPException(status_code=400, detail=f"Command prefix not allowed: {line[:100]}")
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def init_db() -> None:
|
| 145 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 146 |
-
con.execute("PRAGMA journal_mode=WAL")
|
| 147 |
-
con.execute("""CREATE TABLE IF NOT EXISTS receipts (
|
| 148 |
-
id TEXT PRIMARY KEY,
|
| 149 |
-
kind TEXT NOT NULL,
|
| 150 |
-
actor TEXT,
|
| 151 |
-
workspace TEXT,
|
| 152 |
-
summary TEXT,
|
| 153 |
-
payload_json TEXT NOT NULL,
|
| 154 |
-
payload_sha256 TEXT NOT NULL,
|
| 155 |
-
created_at TEXT NOT NULL
|
| 156 |
-
)""")
|
| 157 |
-
con.execute("""CREATE INDEX IF NOT EXISTS idx_receipts_kind_time ON receipts(kind, created_at)""")
|
| 158 |
-
con.execute("""CREATE TABLE IF NOT EXISTS request_log (
|
| 159 |
-
id TEXT PRIMARY KEY,
|
| 160 |
-
method TEXT,
|
| 161 |
-
path TEXT,
|
| 162 |
-
query TEXT,
|
| 163 |
-
client TEXT,
|
| 164 |
-
user_agent TEXT,
|
| 165 |
-
status_code INTEGER,
|
| 166 |
-
duration_ms REAL,
|
| 167 |
-
request_sha256 TEXT,
|
| 168 |
-
response_sha256 TEXT,
|
| 169 |
-
created_at TEXT NOT NULL
|
| 170 |
-
)""")
|
| 171 |
-
con.execute("""CREATE TABLE IF NOT EXISTS tools (
|
| 172 |
-
name TEXT PRIMARY KEY,
|
| 173 |
-
description TEXT,
|
| 174 |
-
mode TEXT NOT NULL,
|
| 175 |
-
template TEXT NOT NULL,
|
| 176 |
-
input_schema_json TEXT DEFAULT '{}',
|
| 177 |
-
required_args_json TEXT DEFAULT '[]',
|
| 178 |
-
enabled INTEGER DEFAULT 1,
|
| 179 |
-
created_at TEXT NOT NULL,
|
| 180 |
-
updated_at TEXT NOT NULL
|
| 181 |
-
)""")
|
| 182 |
-
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
|
| 183 |
-
id TEXT PRIMARY KEY,
|
| 184 |
-
workspace TEXT NOT NULL,
|
| 185 |
-
cwd TEXT NOT NULL,
|
| 186 |
-
created_at TEXT NOT NULL,
|
| 187 |
-
updated_at TEXT NOT NULL
|
| 188 |
-
)""")
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def save_json(path: Path, payload: Any) -> None:
|
| 192 |
-
path.parent.mkdir(parents=True, exist_ok=True)
|
| 193 |
-
tmp = path.with_suffix(path.suffix + ".tmp")
|
| 194 |
-
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str), encoding="utf-8")
|
| 195 |
-
tmp.replace(path)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def make_receipt(kind: str, payload: Dict[str, Any], *, actor: str = "gpt", workspace: Optional[str] = None, summary: str = "") -> Dict[str, Any]:
|
| 199 |
-
rid = f"receipt_{uuid.uuid4().hex[:16]}"
|
| 200 |
-
clean = json.loads(json.dumps(payload, ensure_ascii=False, default=str))
|
| 201 |
-
digest = sha256_json(clean)
|
| 202 |
-
receipt = {
|
| 203 |
-
"id": rid,
|
| 204 |
-
"kind": kind,
|
| 205 |
-
"actor": actor,
|
| 206 |
-
"workspace": workspace,
|
| 207 |
-
"summary": summary or "",
|
| 208 |
-
"payload_sha256": digest,
|
| 209 |
-
"payload": clean,
|
| 210 |
-
"created_at": now_iso(),
|
| 211 |
-
}
|
| 212 |
-
save_json(RECEIPTS_ROOT / f"{rid}.json", receipt)
|
| 213 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 214 |
-
con.execute(
|
| 215 |
-
"INSERT INTO receipts(id, kind, actor, workspace, summary, payload_json, payload_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 216 |
-
(rid, kind, actor, workspace, summary or "", json.dumps(clean, ensure_ascii=False), digest, receipt["created_at"]),
|
| 217 |
-
)
|
| 218 |
-
return {k: v for k, v in receipt.items() if k != "payload"}
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def attach_receipt(payload: Dict[str, Any], kind: str, *, actor: str = "gpt", workspace: Optional[str] = None, summary: str = "") -> Dict[str, Any]:
|
| 222 |
-
try:
|
| 223 |
-
payload["receipt"] = make_receipt(kind, payload, actor=actor, workspace=workspace, summary=summary)
|
| 224 |
-
payload["receipt_error"] = None
|
| 225 |
-
except Exception as e:
|
| 226 |
-
payload["receipt"] = None
|
| 227 |
-
payload["receipt_error"] = f"{type(e).__name__}: {str(e)}"
|
| 228 |
-
return payload
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
def private_or_internal_host(hostname: str) -> bool:
|
| 232 |
-
if not hostname:
|
| 233 |
-
return True
|
| 234 |
-
h = hostname.lower().strip(".")
|
| 235 |
-
if h in {"localhost", "metadata.google.internal"} or h.endswith(".local") or h.endswith(".internal"):
|
| 236 |
-
return True
|
| 237 |
-
try:
|
| 238 |
-
ip = ipaddress.ip_address(h)
|
| 239 |
-
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified
|
| 240 |
-
except ValueError:
|
| 241 |
-
pass
|
| 242 |
-
try:
|
| 243 |
-
infos = socket.getaddrinfo(h, None)
|
| 244 |
-
except socket.gaierror:
|
| 245 |
-
raise HTTPException(status_code=400, detail="Could not resolve hostname")
|
| 246 |
-
for info in infos:
|
| 247 |
-
ip = ipaddress.ip_address(info[4][0])
|
| 248 |
-
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
| 249 |
-
return True
|
| 250 |
-
return False
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
def extract_title_text(raw: bytes, content_type: str) -> Dict[str, str]:
|
| 254 |
-
text = raw.decode("utf-8", errors="replace")
|
| 255 |
-
if "html" in (content_type or "").lower() or "<html" in text[:5000].lower():
|
| 256 |
-
title_match = re.search(r"<title[^>]*>(.*?)</title>", text, flags=re.I | re.S)
|
| 257 |
-
title = html.unescape(re.sub(r"\s+", " ", title_match.group(1)).strip()) if title_match else ""
|
| 258 |
-
no_script = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", text, flags=re.I | re.S)
|
| 259 |
-
no_tags = re.sub(r"<[^>]+>", " ", no_script)
|
| 260 |
-
clean = html.unescape(re.sub(r"\s+", " ", no_tags).strip())
|
| 261 |
-
return {"title": title, "text": clean}
|
| 262 |
-
return {"title": "", "text": re.sub(r"\s+", " ", text).strip()}
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
def render_template(template: str, params: Dict[str, Any], required_args: List[str]) -> str:
|
| 266 |
-
missing = [x for x in required_args if x not in params or params[x] is None]
|
| 267 |
-
if missing:
|
| 268 |
-
raise HTTPException(status_code=422, detail={"missing_required_args": missing})
|
| 269 |
-
rendered = template
|
| 270 |
-
for k, v in params.items():
|
| 271 |
-
val = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
| 272 |
-
rendered = rendered.replace("{{" + k + "}}", val)
|
| 273 |
-
unresolved = re.findall(r"{{\s*([a-zA-Z0-9_.$-]+)\s*}}", rendered)
|
| 274 |
-
if unresolved:
|
| 275 |
-
raise HTTPException(status_code=422, detail={"unresolved_params": unresolved})
|
| 276 |
-
return rendered
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
class TerminalRunRequest(BaseModel):
|
| 280 |
-
workspace: str = "default"
|
| 281 |
-
command: str
|
| 282 |
-
timeout_seconds: int = Field(default=10, ge=1, le=30)
|
| 283 |
-
cwd: str = "."
|
| 284 |
-
create_receipt: bool = True
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
class PythonRunRequest(BaseModel):
|
| 288 |
-
workspace: str = "default"
|
| 289 |
-
code: str
|
| 290 |
-
timeout_seconds: int = Field(default=10, ge=1, le=30)
|
| 291 |
-
create_receipt: bool = True
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
class FileWriteRequest(BaseModel):
|
| 295 |
-
workspace: str = "default"
|
| 296 |
-
path: str
|
| 297 |
-
content: str
|
| 298 |
-
encoding: str = "utf-8"
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
class FileReadRequest(BaseModel):
|
| 302 |
-
workspace: str = "default"
|
| 303 |
-
path: str
|
| 304 |
-
max_bytes: int = Field(default=120000, ge=1, le=20000000)
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
class FileListRequest(BaseModel):
|
| 308 |
-
workspace: str = "default"
|
| 309 |
-
path: str = "."
|
| 310 |
-
max_items: int = Field(default=250, ge=1, le=5000)
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
class ArtifactRequest(BaseModel):
|
| 314 |
-
workspace: str = "default"
|
| 315 |
-
filename: str
|
| 316 |
-
content: str
|
| 317 |
-
content_type: str = "text/plain"
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
class WebScrapeRequest(BaseModel):
|
| 321 |
-
url: str
|
| 322 |
-
workspace: str = "scrapes"
|
| 323 |
-
save: bool = True
|
| 324 |
-
max_bytes: int = Field(default=SCRAPE_MAX_BYTES, ge=1, le=3000000)
|
| 325 |
-
preview_chars: int = Field(default=SCRAPE_PREVIEW_CHARS, ge=100, le=50000)
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
class ToolRegisterRequest(BaseModel):
|
| 329 |
-
name: str
|
| 330 |
-
description: str = ""
|
| 331 |
-
mode: str = Field(default="command", description="command or python")
|
| 332 |
-
template: str
|
| 333 |
-
input_schema: Dict[str, Any] = Field(default_factory=dict)
|
| 334 |
-
required_args: List[str] = []
|
| 335 |
-
enabled: bool = True
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
class ToolInvokeRequest(BaseModel):
|
| 339 |
-
workspace: str = "default"
|
| 340 |
-
params: Dict[str, Any] = Field(default_factory=dict)
|
| 341 |
-
timeout_seconds: int = Field(default=10, ge=1, le=30)
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
class SessionCreateRequest(BaseModel):
|
| 345 |
-
workspace: str = "default"
|
| 346 |
-
cwd: str = "."
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
class SessionRunRequest(BaseModel):
|
| 350 |
-
session_id: str
|
| 351 |
-
command: str
|
| 352 |
-
timeout_seconds: int = Field(default=10, ge=1, le=30)
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
class AnalyzeRequest(BaseModel):
|
| 356 |
-
request_text: str
|
| 357 |
-
context: Dict[str, Any] = {}
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
class RelaunchRequest(BaseModel):
|
| 361 |
-
reason: str = "manual relaunch requested by Custom GPT"
|
| 362 |
-
delay_seconds: int = Field(default=1, ge=0, le=10)
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
@app.middleware("http")
|
| 366 |
-
async def request_analysis_middleware(request: Request, call_next):
|
| 367 |
-
started = time.time()
|
| 368 |
-
body = b""
|
| 369 |
-
if request.method in {"POST", "PUT", "PATCH"}:
|
| 370 |
-
body = await request.body()
|
| 371 |
-
|
| 372 |
-
async def receive():
|
| 373 |
-
return {"type": "http.request", "body": body, "more_body": False}
|
| 374 |
-
request = Request(request.scope, receive)
|
| 375 |
-
|
| 376 |
-
response = await call_next(request)
|
| 377 |
-
duration_ms = round((time.time() - started) * 1000, 3)
|
| 378 |
-
|
| 379 |
-
try:
|
| 380 |
-
rid = f"req_{uuid.uuid4().hex[:16]}"
|
| 381 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 382 |
-
con.execute(
|
| 383 |
-
"INSERT INTO request_log(id, method, path, query, client, user_agent, status_code, duration_ms, request_sha256, response_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
| 384 |
-
(
|
| 385 |
-
rid,
|
| 386 |
-
request.method,
|
| 387 |
-
request.url.path,
|
| 388 |
-
str(request.url.query),
|
| 389 |
-
request.client.host if request.client else "",
|
| 390 |
-
request.headers.get("user-agent", ""),
|
| 391 |
-
response.status_code,
|
| 392 |
-
duration_ms,
|
| 393 |
-
sha256_bytes(body),
|
| 394 |
-
"",
|
| 395 |
-
now_iso(),
|
| 396 |
-
),
|
| 397 |
-
)
|
| 398 |
-
except Exception:
|
| 399 |
-
pass
|
| 400 |
-
return response
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
@app.on_event("startup")
|
| 404 |
-
def startup():
|
| 405 |
-
init_db()
|
| 406 |
-
# Ensure quadrant workspaces exist
|
| 407 |
-
for qws in ["quad-a", "quad-b", "quad-c", "quad-d"]:
|
| 408 |
-
(WORKSPACE_ROOT / qws).mkdir(parents=True, exist_ok=True)
|
| 409 |
-
try:
|
| 410 |
-
ws_dir = os.path.join(os.environ.get("WORKSPACE_DIR", "/data/workspaces"), "default")
|
| 411 |
-
os.makedirs(ws_dir, exist_ok=True)
|
| 412 |
-
ui_path = os.path.join(ws_dir, "morphos_ui.html")
|
| 413 |
-
if not os.path.exists(ui_path):
|
| 414 |
-
# Embed the MorphOS dashboard inline
|
| 415 |
-
morphos_ui = """<!DOCTYPE html>
|
| 416 |
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 417 |
-
<title>MorphOS — Multi-Node Orchestrator</title>
|
| 418 |
-
<style>
|
| 419 |
-
*{margin:0;padding:0;box-sizing:border-box}
|
| 420 |
-
body{background:#0a0a0f;color:#e0e0e8;font-family:'SF Mono',Monaco,monospace}
|
| 421 |
-
.header{padding:12px 24px;background:rgba(10,10,15,0.95);border-bottom:1px solid #1a1a2e;display:flex;align-items:center;justify-content:space-between}
|
| 422 |
-
.logo{font-size:18px;font-weight:bold;color:#7c5cfc}.logo span{color:#e0e0e8}
|
| 423 |
-
.main{padding:24px;display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
| 424 |
-
@media(max-width:768px){.main{grid-template-columns:1fr}}
|
| 425 |
-
.card{background:#12121a;border:1px solid #1e1e30;border-radius:12px;padding:20px}
|
| 426 |
-
.card h2{font-size:14px;color:#7c5cfc;margin-bottom:16px;text-transform:uppercase;letter-spacing:1px}
|
| 427 |
-
.node{background:#1a1a2e;border-radius:8px;padding:14px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
|
| 428 |
-
.node-name{font-size:14px;font-weight:600}.node-kind{font-size:11px;color:#666}
|
| 429 |
-
.node-status{font-size:11px;padding:4px 10px;border-radius:20px}
|
| 430 |
-
.node-status.online{background:rgba(74,222,128,0.15);color:#4ade80}
|
| 431 |
-
.node-status.offline{background:rgba(248,113,113,0.15);color:#f87171}
|
| 432 |
-
.terminal{background:#08080d;border-radius:8px;padding:16px;font-size:13px;max-height:300px;overflow-y:auto;border:1px solid #1e1e30}
|
| 433 |
-
.terminal-line{color:#4ade80;margin-bottom:4px}.terminal-line.info{color:#60a5fa}.terminal-line.error{color:#f87171}
|
| 434 |
-
.btn{padding:10px 20px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-family:inherit}
|
| 435 |
-
.btn-pause{background:rgba(250,204,21,0.2);color:#facc15;border:1px solid #facc15}
|
| 436 |
-
.btn-resume{background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid #4ade80}
|
| 437 |
-
.cmd-input{display:flex;gap:8px;margin-top:12px}
|
| 438 |
-
.cmd-input input{flex:1;background:#08080d;border:1px solid #2a2a40;color:#e0e0e8;padding:10px;border-radius:8px;font-family:monospace}
|
| 439 |
-
.cmd-input button{background:#7c5cfc;color:#fff;border:none;padding:10px 20px;border-radius:8px;cursor:pointer}
|
| 440 |
-
.app-tag{background:#1a1a2e;padding:6px 12px;border-radius:20px;font-size:12px;border:1px solid #2a2a40;display:inline-block;margin:4px}
|
| 441 |
-
.app-tag.frontmost{border-color:#7c5cfc;color:#7c5cfc}
|
| 442 |
-
.chain{background:#1a1a2e;border-radius:8px;padding:12px;margin-bottom:8px;cursor:pointer}
|
| 443 |
-
.chain-prompt{font-size:13px;margin-bottom:4px}.chain-meta{font-size:11px;color:#666}
|
| 444 |
-
</style></head><body>
|
| 445 |
-
<div class="header"><div class="logo">Morph<span>OS</span></div>
|
| 446 |
-
<div style="font-size:12px;display:flex;gap:16px">
|
| 447 |
-
<span><span style="color:#4ade80">●</span> <span id="health">connecting</span></span>
|
| 448 |
-
<span><span id="node-count">0</span> nodes</span>
|
| 449 |
-
<span><span id="chain-count">0</span> chains</span>
|
| 450 |
-
</div></div>
|
| 451 |
-
<div class="main">
|
| 452 |
-
<div class="card"><h2>Nodes</h2><div id="nodes"></div>
|
| 453 |
-
<div style="margin-top:16px;display:flex;gap:12px">
|
| 454 |
-
<button class="btn btn-pause" onclick="speak(true)">Pause</button>
|
| 455 |
-
<button class="btn btn-resume" onclick="speak(false)">Resume</button></div></div>
|
| 456 |
-
<div class="card"><h2>Prompt Chains</h2><div id="chains" style="max-height:300px;overflow-y:auto">No chains yet</div></div>
|
| 457 |
-
<div class="card"><h2>Terminal</h2><div class="terminal" id="term"><div class="terminal-line info">MorphOS terminal — commands run on this node.</div></div>
|
| 458 |
-
<div class="cmd-input"><input id="cmd" placeholder="Run command..." onkeypress="if(event.key==='Enter')run()"><button onclick="run()">Run</button></div></div>
|
| 459 |
-
<div class="card"><h2>Query</h2><div class="cmd-input"><input id="q" placeholder="What did my prompt do?" onkeypress="if(event.key==='Enter')ask()"><button onclick="ask()">Ask</button></div>
|
| 460 |
-
<div class="terminal" id="qr" style="margin-top:12px"><div class="terminal-line info">Ask about chains.</div></div></div>
|
| 461 |
-
</div>
|
| 462 |
-
<script>
|
| 463 |
-
const A=location.origin;const K='';
|
| 464 |
-
async function api(p,m='GET',b){const o={method:m,headers:{'Content-Type':'application/json'}};if(b)o.body=JSON.stringify(b);try{return await(await fetch(A+p,o)).json()}catch(e){return{error:e.message}}}
|
| 465 |
-
async function r(){const h=await api('/health');document.getElementById('health').textContent=h.status||'?';document.getElementById('node-count').textContent=h.nodes||0;document.getElementById('chain-count').textContent=h.receipts||0;
|
| 466 |
-
try{const n=await api('/nodes');const el=document.getElementById('nodes');if(n.nodes&&n.nodes.length)el.innerHTML=n.nodes.map(x=>`<div class="node"><div><div class="node-name">${x.name}</div><div class="node-kind">${x.kind}</div></div><div class="node-status ${x.status}">${x.status}</div></div>`).join('');else el.innerHTML='<span style="color:#666">No nodes</span>'}catch(e){}try{const c=await api('/chains');const cl=document.getElementById('chains');if(c.chains&&c.chains.length)cl.innerHTML=c.chains.slice(-10).reverse().map(x=>`<div class="chain"><div class="chain-prompt">${x.text}</div><div class="chain-meta">${x.events} events — ${x.status}</div></div>`).join('')}catch(e){}}
|
| 467 |
-
async function speak(s){await api('/speak','POST',{speaking:s});r()}
|
| 468 |
-
async function run(){const c=document.getElementById('cmd').value;if(!c)return;const t=document.getElementById('term');t.innerHTML+=`<div class="terminal-line info">> ${c}</div>`;const res=await api('/terminal/run','POST',{command:c,node:'auto'});if(res.stdout)t.innerHTML+=`<div class="terminal-line">${res.stdout}</div>`;if(res.stderr)t.innerHTML+=`<div class="terminal-line error">${res.stderr}</div>`;if(res.error)t.innerHTML+=`<div class="terminal-line error">${res.error}</div>`;t.scrollTop=t.scrollHeight;document.getElementById('cmd').value=''}
|
| 469 |
-
async function ask(){const q=document.getElementById('q').value;if(!q)return;const el=document.getElementById('qr');el.innerHTML=`<div class="terminal-line info">Q: ${q}</div>`;const r=await api('/query','POST',{question:q});el.innerHTML+=`<div class="terminal-line">${JSON.stringify(r,null,2)}</div>`;el.scrollTop=el.scrollHeight;document.getElementById('q').value=''}
|
| 470 |
-
r();setInterval(r,5000);
|
| 471 |
-
</script></body></html>"""
|
| 472 |
-
with open(ui_path, "w") as f:
|
| 473 |
-
f.write(morphos_ui)
|
| 474 |
-
print(f"Wrote MorphOS UI to {ui_path}")
|
| 475 |
-
except Exception as e:
|
| 476 |
-
print(f"Could not write MorphOS UI: {e}")
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
# ── MorphOS 4-Quadrant UI (embedded) ──
|
| 480 |
-
MORPHOS_QUAD_UI = """<!DOCTYPE html>
|
| 481 |
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 482 |
-
<title>MorphOS — 4-Quadrant Terminal Lab</title>
|
| 483 |
-
<style>
|
| 484 |
-
*{margin:0;padding:0;box-sizing:border-box}
|
| 485 |
-
body{background:#06060a;color:#c0c0d0;font-family:'SF Mono',Monaco,monospace;overflow:hidden;height:100vh}
|
| 486 |
-
.header{height:44px;padding:0 20px;background:rgba(10,10,18,0.98);border-bottom:1px solid #1a1a2e;display:flex;align-items:center;justify-content:space-between;z-index:100}
|
| 487 |
-
.logo{font-size:16px;font-weight:bold;color:#7c5cfc}.logo span{color:#c0c0d0}
|
| 488 |
-
.header-info{font-size:11px;color:#555;display:flex;gap:16px;align-items:center}
|
| 489 |
-
.header-info .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
|
| 490 |
-
.dot.green{background:#4ade80}.dot.red{background:#f87171}.dot.yellow{background:#facc15}
|
| 491 |
-
.grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;height:calc(100vh - 44px);gap:1px;background:#0a0a14}
|
| 492 |
-
.quadrant{background:#0a0a10;display:flex;flex-direction:column;overflow:hidden;position:relative}
|
| 493 |
-
.quadrant-header{height:32px;padding:0 12px;background:#0e0e18;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
| 494 |
-
.quadrant-title{font-size:12px;font-weight:600;color:#7c5cfc;display:flex;align-items:center;gap:8px}
|
| 495 |
-
.quadrant-title .qbadge{background:#1a1a2e;padding:2px 8px;border-radius:4px;font-size:10px;color:#888}
|
| 496 |
-
.quadrant-status{font-size:10px;color:#555}
|
| 497 |
-
.quadrant-status .dot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:3px}
|
| 498 |
-
.terminal{flex:1;overflow-y:auto;padding:10px 12px;font-size:12px;line-height:1.5;background:#06060a;cursor:text}
|
| 499 |
-
.terminal .line{white-space:pre-wrap;word-break:break-all}
|
| 500 |
-
.terminal .line.cmd{color:#7c5cfc}.terminal .line.out{color:#c0c0d0}.terminal .line.err{color:#f87171}
|
| 501 |
-
.terminal .line.sys{color:#60a5fa}.terminal .line.ok{color:#4ade80}.terminal .line.dim{color:#444}
|
| 502 |
-
.cmd-bar{display:flex;gap:0;background:#0e0e18;border-top:1px solid #1a1a2e;flex-shrink:0}
|
| 503 |
-
.cmd-bar .prompt{color:#7c5cfc;padding:8px 10px;font-size:12px;user-select:none}
|
| 504 |
-
.cmd-bar input{flex:1;background:transparent;border:none;color:#c0c0d0;padding:8px 4px;font-family:inherit;font-size:12px;outline:none}
|
| 505 |
-
.cmd-bar input::placeholder{color:#333}
|
| 506 |
-
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:200;display:none;align-items:center;justify-content:center}
|
| 507 |
-
.modal-overlay.active{display:flex}
|
| 508 |
-
.modal{background:#12121a;border:1px solid #2a2a40;border-radius:12px;padding:24px;max-width:500px;width:90%}
|
| 509 |
-
.modal h2{color:#7c5cfc;font-size:16px;margin-bottom:16px}
|
| 510 |
-
.modal p{color:#888;font-size:13px;margin-bottom:16px;line-height:1.6}
|
| 511 |
-
.modal .field{margin-bottom:12px}.modal label{font-size:11px;color:#555;display:block;margin-bottom:4px}
|
| 512 |
-
.modal input,.modal select{width:100%;background:#08080d;border:1px solid #2a2a40;color:#c0c0d0;padding:8px 10px;border-radius:6px;font-family:inherit;font-size:12px}
|
| 513 |
-
.modal .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
| 514 |
-
.modal .btn{padding:8px 16px;border-radius:6px;border:none;cursor:pointer;font-size:12px;font-family:inherit}
|
| 515 |
-
.modal .btn-primary{background:#7c5cfc;color:#fff}.modal .btn-cancel{background:#1a1a2e;color:#888;border:1px solid #2a2a40}
|
| 516 |
-
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
| 517 |
-
.working{animation:pulse 1.5s ease-in-out infinite}
|
| 518 |
-
</style></head><body>
|
| 519 |
-
<div class="header"><div class="logo">Morph<span>OS</span> — Quad Terminal Lab</div>
|
| 520 |
-
<div class="header-info">
|
| 521 |
-
<span><span class="dot green" id="conn-dot"></span><span id="conn-status">Connected</span></span>
|
| 522 |
-
<span id="speaking-state">Morphing</span>
|
| 523 |
-
<button id="speak-btn" onclick="toggleSpeak()" style="background:rgba(250,204,21,0.15);color:#facc15;border:1px solid #facc15;padding:4px 12px;border-radius:6px;cursor:pointer;font-size:11px;font-family:inherit">Pause</button>
|
| 524 |
-
</div></div>
|
| 525 |
-
<div class="grid" id="grid">
|
| 526 |
-
<div class="quadrant" id="q1" data-qid="q1" data-name="App Lab A" data-workspace="quad-a">
|
| 527 |
-
<div class="quadrant-header"><div class="quadrant-title"><span class="qbadge">Q1</span><span id="q1-name">App Lab A</span></div>
|
| 528 |
-
<div class="quadrant-status"><span class="dot green" id="q1-dot"></span><span id="q1-status">idle</span></div></div>
|
| 529 |
-
<div class="terminal" id="q1-term" onclick="focusQuad('q1')"></div>
|
| 530 |
-
<div class="cmd-bar"><span class="prompt">$</span><input id="q1-input" placeholder="type command or 'help'..." onkeydown="if(event.key==='Enter')runCmd('q1',this.value);if(event.key==='ArrowUp')historyNav('q1',-1);if(event.key==='ArrowDown')historyNav('q1',1)"></div>
|
| 531 |
-
</div>
|
| 532 |
-
<div class="quadrant" id="q2" data-qid="q2" data-name="App Lab B" data-workspace="quad-b">
|
| 533 |
-
<div class="quadrant-header"><div class="quadrant-title"><span class="qbadge">Q2</span><span id="q2-name">App Lab B</span></div>
|
| 534 |
-
<div class="quadrant-status"><span class="dot green" id="q2-dot"></span><span id="q2-status">idle</span></div></div>
|
| 535 |
-
<div class="terminal" id="q2-term" onclick="focusQuad('q2')"></div>
|
| 536 |
-
<div class="cmd-bar"><span class="prompt">$</span><input id="q2-input" placeholder="type command or 'help'..." onkeydown="if(event.key==='Enter')runCmd('q2',this.value);if(event.key==='ArrowUp')historyNav('q2',-1);if(event.key==='ArrowDown')historyNav('q2',1)"></div>
|
| 537 |
-
</div>
|
| 538 |
-
<div class="quadrant" id="q3" data-qid="q3" data-name="App Lab C" data-workspace="quad-c">
|
| 539 |
-
<div class="quadrant-header"><div class="quadrant-title"><span class="qbadge">Q3</span><span id="q3-name">App Lab C</span></div>
|
| 540 |
-
<div class="quadrant-status"><span class="dot green" id="q3-dot"></span><span id="q3-status">idle</span></div></div>
|
| 541 |
-
<div class="terminal" id="q3-term" onclick="focusQuad('q3')"></div>
|
| 542 |
-
<div class="cmd-bar"><span class="prompt">$</span><input id="q3-input" placeholder="type command or 'help'..." onkeydown="if(event.key==='Enter')runCmd('q3',this.value);if(event.key==='ArrowUp')historyNav('q3',-1);if(event.key==='ArrowDown')historyNav('q3',1)"></div>
|
| 543 |
-
</div>
|
| 544 |
-
<div class="quadrant" id="q4" data-qid="q4" data-name="App Lab D" data-workspace="quad-d">
|
| 545 |
-
<div class="quadrant-header"><div class="quadrant-title"><span class="qbadge">Q4</span><span id="q4-name">App Lab D</span></div>
|
| 546 |
-
<div class="quadrant-status"><span class="dot green" id="q4-dot"></span><span id="q4-status">idle</span></div></div>
|
| 547 |
-
<div class="terminal" id="q4-term" onclick="focusQuad('q4')"></div>
|
| 548 |
-
<div class="cmd-bar"><span class="prompt">$</span><input id="q4-input" placeholder="type command or 'help'..." onkeydown="if(event.key==='Enter')runCmd('q4',this.value);if(event.key==='ArrowUp')historyNav('q4',-1);if(event.key==='ArrowDown')historyNav('q4',1)"></div>
|
| 549 |
-
</div>
|
| 550 |
-
</div>
|
| 551 |
-
<div class="modal-overlay" id="rename-modal"><div class="modal"><h2>Configure Quadrant</h2>
|
| 552 |
-
<p>Each quadrant is an independent Linux terminal workspace. ChatGPT can target any quadrant by workspace name.</p>
|
| 553 |
-
<div class="field"><label>Quadrant</label><select id="rename-qid"><option value="q1">Q1</option><option value="q2">Q2</option><option value="q3">Q3</option><option value="q4">Q4</option></select></div>
|
| 554 |
-
<div class="field"><label>App Name</label><input id="rename-name" placeholder="e.g. React Frontend"></div>
|
| 555 |
-
<div class="field"><label>Workspace (for API calls)</label><input id="rename-ws" placeholder="e.g. quad-a"></div>
|
| 556 |
-
<div class="btns"><button class="btn btn-cancel" onclick="closeModal()">Cancel</button><button class="btn btn-primary" onclick="applyRename()">Save</button></div>
|
| 557 |
-
</div></div>
|
| 558 |
-
<script>
|
| 559 |
-
const QUADS=['q1','q2','q3','q4'];const API_BASE=window.location.origin;let focused='q1';let histories={q1:[],q2:[],q3:[],q4:[]};let histIdx={q1:-1,q2:-1,q3:-1,q4:-1};let speaking=false;
|
| 560 |
-
function addLine(qid,text,cls='out'){const t=document.getElementById(qid+'-term');const d=document.createElement('div');d.className='line '+cls;d.textContent=text;t.appendChild(d);t.scrollTop=t.scrollHeight;}
|
| 561 |
-
function setStatus(qid,status,color='green'){document.getElementById(qid+'-status').textContent=status;const dot=document.getElementById(qid+'-dot');dot.className='dot '+color;if(status==='working')dot.classList.add('working');}
|
| 562 |
-
async function runCmd(qid,cmd){if(!cmd.trim())return;const input=document.getElementById(qid+'-input');input.value='';histories[qid].push(cmd);histIdx[qid]=histories[qid].length;
|
| 563 |
-
if(cmd.trim()==='help'){addLine(qid,'-- MorphOS Quad Terminal --','sys');addLine(qid,'Each quadrant = independent Linux workspace.','dim');addLine(qid,'Special: help, clear, rename, apps, ls','dim');return;}
|
| 564 |
-
if(cmd.trim()==='clear'){document.getElementById(qid+'-term').innerHTML='';return;}
|
| 565 |
-
if(cmd.trim()==='rename'){openRenameModal(qid);return;}
|
| 566 |
-
if(cmd.trim()==='apps'){QUADS.forEach(q2=>{addLine(qid,q2.toUpperCase()+': '+document.getElementById(q2).dataset.name+' (ws: '+document.getElementById(q2).dataset.workspace+')','out');});return;}
|
| 567 |
-
const ws=document.getElementById(qid).dataset.workspace;addLine(qid,'$ '+cmd,'cmd');setStatus(qid,'working','yellow');
|
| 568 |
-
try{const r=await fetch(API_BASE+'/terminal/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({command:cmd,workspace:ws,timeout_seconds:25,create_receipt:false})});const d=await r.json();if(d.stdout)addLine(qid,d.stdout,'out');if(d.stderr)addLine(qid,d.stderr,'err');const rc=d.returncode??0;if(rc===0){setStatus(qid,'idle','green');addLine(qid,'[exit '+rc+']','dim');}else{setStatus(qid,'error','red');addLine(qid,'[exit '+rc+']','err');}}catch(e){addLine(qid,'Error: '+e.message,'err');setStatus(qid,'offline','red');}}
|
| 569 |
-
function historyNav(qid,dir){const h=histories[qid];if(!h.length)return;histIdx[qid]=Math.max(-1,Math.min(h.length-1,histIdx[qid]+dir));document.getElementById(qid+'-input').value=histIdx[qid]>=0?h[histIdx[qid]]:'';}
|
| 570 |
-
function focusQuad(qid){focused=qid;QUADS.forEach(q=>{document.getElementById(q).style.border=q===qid?'1px solid #7c5cfc':'none';});setTimeout(()=>document.getElementById(qid+'-input').focus(),0);}
|
| 571 |
-
async function toggleSpeak(){speaking=!speaking;const btn=document.getElementById('speak-btn');const st=document.getElementById('speaking-state');try{await fetch(API_BASE+'/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({speaking:speaking})});}catch{}if(speaking){btn.textContent='Resume';btn.style.color='#4ade80';btn.style.borderColor='#4ade80';st.textContent='Paused';st.style.color='#facc15';}else{btn.textContent='Pause';btn.style.color='#facc15';btn.style.borderColor='#facc15';st.textContent='Morphing';st.style.color='#4ade80';}}
|
| 572 |
-
function openRenameModal(qid){document.getElementById('rename-qid').value=qid;document.getElementById('rename-name').value=document.getElementById(qid).dataset.name;document.getElementById('rename-ws').value=document.getElementById(qid).dataset.workspace;document.getElementById('rename-modal').classList.add('active');}
|
| 573 |
-
function closeModal(){document.getElementById('rename-modal').classList.remove('active');}
|
| 574 |
-
function applyRename(){const qid=document.getElementById('rename-qid').value;const n=document.getElementById('rename-name').value;const w=document.getElementById('rename-ws').value;document.getElementById(qid).dataset.name=n;document.getElementById(qid).dataset.workspace=w;document.getElementById(qid+'-name').textContent=n;addLine(qid,'Renamed: '+n+' (ws: '+w+')','sys');closeModal();}
|
| 575 |
-
async function healthCheck(){try{await fetch(API_BASE+'/health');document.getElementById('conn-status').textContent='Connected';document.getElementById('conn-dot').className='dot green';}catch{document.getElementById('conn-status').textContent='Offline';document.getElementById('conn-dot').className='dot red';}}
|
| 576 |
-
async function pollGPT(){try{const r=await fetch(API_BASE+'/receipts?limit=5');const d=await r.json();if(d.receipts){d.receipts.forEach(r=>{if(r.kind==='terminal.run'&&r.workspace){const qid=QUADS.find(q=>document.getElementById(q).dataset.workspace===r.workspace);if(qid&&r.summary){const ex=document.querySelector('[data-rc="'+r.id+'"]');if(!ex)addLine(qid,'[GPT] '+r.summary,'sys');}}});}}catch{}}
|
| 577 |
-
QUADS.forEach(qid=>{addLine(qid,'-- MorphOS Quad Terminal --','sys');addLine(qid,'Workspace: '+document.getElementById(qid).dataset.workspace,'dim');addLine(qid,"Type 'help'. GPT targets via workspace name.",'dim');addLine(qid,'','dim');});
|
| 578 |
-
focusQuad('q1');healthCheck();setInterval(healthCheck,15000);setInterval(pollGPT,5000);
|
| 579 |
-
</script></body></html>"""
|
| 580 |
-
|
| 581 |
-
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 582 |
-
def home():
|
| 583 |
-
return HTMLResponse(MORPHOS_QUAD_UI)
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
@app.get("/favicon.ico", include_in_schema=False)
|
| 587 |
-
def favicon():
|
| 588 |
-
return PlainTextResponse("", status_code=204)
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
@app.get("/health", operation_id="health")
|
| 592 |
-
def health():
|
| 593 |
return {
|
| 594 |
-
"
|
| 595 |
-
"
|
| 596 |
-
"
|
| 597 |
-
"
|
| 598 |
-
"openapi_url": f"{PUBLIC_BASE_URL}/openapi.json",
|
| 599 |
-
"admin_token_enabled": bool(ADMIN_TOKEN),
|
| 600 |
-
"time": now_iso(),
|
| 601 |
}
|
| 602 |
|
| 603 |
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
-
@app.get("/speak", operation_id="speak_status")
|
| 619 |
-
def speak_status():
|
| 620 |
-
return {"speaking": _SPEAKING, "state": "paused" if _SPEAKING else "morphing"}
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
@app.get("/capabilities", operation_id="get_capabilities")
|
| 624 |
-
def capabilities():
|
| 625 |
return {
|
| 626 |
-
"
|
| 627 |
-
"
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
|
|
|
|
|
|
| 640 |
],
|
| 641 |
-
"mutation_auth": "X-Admin-Token required for relaunch and persistent tool mutation when ADMIN_TOKEN is set",
|
| 642 |
-
"schema": f"{PUBLIC_BASE_URL}/openapi.json",
|
| 643 |
-
}
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
@app.post("/terminal/run", operation_id="run_terminal")
|
| 647 |
-
async def terminal_run(req: TerminalRunRequest):
|
| 648 |
-
validate_command(req.command)
|
| 649 |
-
cwd = workspace_path(req.workspace, req.cwd)
|
| 650 |
-
proc = await asyncio.create_subprocess_shell(
|
| 651 |
-
req.command,
|
| 652 |
-
cwd=str(cwd),
|
| 653 |
-
stdout=asyncio.subprocess.PIPE,
|
| 654 |
-
stderr=asyncio.subprocess.PIPE,
|
| 655 |
-
env={**os.environ, "HOME": str(workspace_path(req.workspace, "."))},
|
| 656 |
-
)
|
| 657 |
-
try:
|
| 658 |
-
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=req.timeout_seconds)
|
| 659 |
-
except asyncio.TimeoutError:
|
| 660 |
-
proc.kill()
|
| 661 |
-
await proc.communicate()
|
| 662 |
-
payload = {"workspace": req.workspace, "cwd": str(cwd), "command": req.command, "returncode": 124, "stdout": "", "stderr": "Command timed out", "truncated": False}
|
| 663 |
-
return attach_receipt(payload, "terminal.timeout", workspace=req.workspace, summary=req.command[:240])
|
| 664 |
-
|
| 665 |
-
payload = {
|
| 666 |
-
"workspace": req.workspace,
|
| 667 |
-
"cwd": str(cwd),
|
| 668 |
-
"command": req.command,
|
| 669 |
-
"returncode": int(proc.returncode or 0),
|
| 670 |
-
"stdout": stdout[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace"),
|
| 671 |
-
"stderr": stderr[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace"),
|
| 672 |
-
"truncated": len(stdout) > MAX_OUTPUT_BYTES or len(stderr) > MAX_OUTPUT_BYTES,
|
| 673 |
-
}
|
| 674 |
-
if req.create_receipt:
|
| 675 |
-
payload = attach_receipt(payload, "terminal.run", workspace=req.workspace, summary=req.command[:240])
|
| 676 |
-
return payload
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
@app.post("/python/run", operation_id="run_python")
|
| 680 |
-
async def python_run(req: PythonRunRequest):
|
| 681 |
-
ws = workspace_path(req.workspace, ".")
|
| 682 |
-
run_id = uuid.uuid4().hex[:12]
|
| 683 |
-
script = ws / f"_run_{run_id}.py"
|
| 684 |
-
script.write_text(req.code, encoding="utf-8")
|
| 685 |
-
proc = await asyncio.create_subprocess_shell(
|
| 686 |
-
f"python {shlex.quote(script.name)}",
|
| 687 |
-
cwd=str(ws),
|
| 688 |
-
stdout=asyncio.subprocess.PIPE,
|
| 689 |
-
stderr=asyncio.subprocess.PIPE,
|
| 690 |
-
env={**os.environ, "HOME": str(ws), "PYTHONUNBUFFERED": "1"},
|
| 691 |
-
)
|
| 692 |
-
try:
|
| 693 |
-
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=req.timeout_seconds)
|
| 694 |
-
except asyncio.TimeoutError:
|
| 695 |
-
proc.kill()
|
| 696 |
-
await proc.communicate()
|
| 697 |
-
payload = {"workspace": req.workspace, "script": script.name, "returncode": 124, "stdout": "", "stderr": "Python timed out", "truncated": False}
|
| 698 |
-
return attach_receipt(payload, "python.timeout", workspace=req.workspace, summary=script.name)
|
| 699 |
-
|
| 700 |
-
payload = {
|
| 701 |
-
"workspace": req.workspace,
|
| 702 |
-
"script": script.name,
|
| 703 |
-
"returncode": int(proc.returncode or 0),
|
| 704 |
-
"stdout": stdout[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace"),
|
| 705 |
-
"stderr": stderr[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace"),
|
| 706 |
-
"truncated": len(stdout) > MAX_OUTPUT_BYTES or len(stderr) > MAX_OUTPUT_BYTES,
|
| 707 |
}
|
| 708 |
-
if req.create_receipt:
|
| 709 |
-
payload = attach_receipt(payload, "python.run", workspace=req.workspace, summary=script.name)
|
| 710 |
-
return payload
|
| 711 |
|
| 712 |
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
|
|
|
| 723 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
-
@app.post("/files/read", operation_id="read_file")
|
| 726 |
-
def files_read(req: FileReadRequest):
|
| 727 |
-
target = workspace_path(req.workspace, req.path)
|
| 728 |
-
if not target.exists() or not target.is_file():
|
| 729 |
-
raise HTTPException(status_code=404, detail="File not found")
|
| 730 |
-
raw_all = target.read_bytes()
|
| 731 |
-
raw = raw_all[: req.max_bytes]
|
| 732 |
return {
|
| 733 |
-
"
|
| 734 |
-
"
|
| 735 |
-
"
|
| 736 |
-
"
|
| 737 |
-
"
|
| 738 |
-
"
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
raise HTTPException(status_code=404, detail="Path not found")
|
| 747 |
-
root = workspace_path(req.workspace, ".")
|
| 748 |
-
items = []
|
| 749 |
-
for idx, p in enumerate(sorted(base.iterdir(), key=lambda x: x.name.lower())):
|
| 750 |
-
if idx >= req.max_items:
|
| 751 |
-
break
|
| 752 |
-
items.append({
|
| 753 |
-
"name": p.name,
|
| 754 |
-
"path": str(p.relative_to(root)),
|
| 755 |
-
"type": "dir" if p.is_dir() else "file",
|
| 756 |
-
"bytes": p.stat().st_size if p.is_file() else None,
|
| 757 |
-
"modified_at": datetime.fromtimestamp(p.stat().st_mtime, timezone.utc).isoformat(),
|
| 758 |
-
})
|
| 759 |
-
return {"workspace": req.workspace, "path": req.path, "items": items}
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
@app.get("/workspace/tree", response_class=PlainTextResponse, operation_id="get_workspace_tree")
|
| 763 |
-
def workspace_tree(workspace: str = "default", max_items: int = 500):
|
| 764 |
-
base = workspace_path(workspace, ".")
|
| 765 |
-
lines = [f"{workspace}/"]
|
| 766 |
-
count = 0
|
| 767 |
-
for p in sorted(base.rglob("*")):
|
| 768 |
-
count += 1
|
| 769 |
-
if count > max_items:
|
| 770 |
-
lines.append("... truncated ...")
|
| 771 |
-
break
|
| 772 |
-
rel = p.relative_to(base)
|
| 773 |
-
indent = " " * (len(rel.parts) - 1)
|
| 774 |
-
lines.append(f"{indent}{rel.name}{'/' if p.is_dir() else ''}")
|
| 775 |
-
return "\n".join(lines)
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
@app.post("/artifact/compile", operation_id="compile_artifact")
|
| 779 |
-
def artifact_compile(req: ArtifactRequest):
|
| 780 |
-
filename = safe_name(req.filename, "artifact.txt")
|
| 781 |
-
if "." not in filename:
|
| 782 |
-
filename += ".txt"
|
| 783 |
-
aid = f"art_{uuid.uuid4().hex[:12]}_{filename}"
|
| 784 |
-
target = artifact_path(aid)
|
| 785 |
-
raw = req.content.encode("utf-8")
|
| 786 |
-
if len(raw) > MAX_FILE_BYTES:
|
| 787 |
-
raise HTTPException(status_code=413, detail="Artifact too large")
|
| 788 |
-
target.write_bytes(raw)
|
| 789 |
-
payload = {
|
| 790 |
-
"artifact_id": aid,
|
| 791 |
-
"filename": filename,
|
| 792 |
-
"bytes": len(raw),
|
| 793 |
-
"sha256": sha256_bytes(raw),
|
| 794 |
-
"url": f"{PUBLIC_BASE_URL}/u/{aid}",
|
| 795 |
-
"content_type": req.content_type,
|
| 796 |
-
}
|
| 797 |
-
return attach_receipt(payload, "artifact.compile", workspace=req.workspace, summary=filename)
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
@app.post("/url/issue", operation_id="issue_url")
|
| 801 |
-
def url_issue(req: ArtifactRequest):
|
| 802 |
-
return artifact_compile(req)
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
@app.get("/u/{artifact_id}", include_in_schema=False)
|
| 806 |
-
def artifact_get(artifact_id: str):
|
| 807 |
-
p = artifact_path(artifact_id)
|
| 808 |
-
if not p.exists() or not p.is_file():
|
| 809 |
-
raise HTTPException(status_code=404, detail="Artifact not found")
|
| 810 |
-
return FileResponse(str(p), filename=p.name)
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
@app.get("/artifacts", operation_id="list_artifacts")
|
| 814 |
-
def list_artifacts(limit: int = 100):
|
| 815 |
-
items = []
|
| 816 |
-
for idx, p in enumerate(sorted(ARTIFACT_ROOT.glob("*"), key=lambda x: x.stat().st_mtime, reverse=True)):
|
| 817 |
-
if idx >= max(1, min(limit, 1000)):
|
| 818 |
-
break
|
| 819 |
-
if not p.is_file():
|
| 820 |
-
continue
|
| 821 |
-
raw = p.read_bytes()
|
| 822 |
-
items.append({
|
| 823 |
-
"artifact_id": p.name,
|
| 824 |
-
"bytes": len(raw),
|
| 825 |
-
"sha256": sha256_bytes(raw),
|
| 826 |
-
"url": f"{PUBLIC_BASE_URL}/u/{p.name}",
|
| 827 |
-
"modified_at": datetime.fromtimestamp(p.stat().st_mtime, timezone.utc).isoformat(),
|
| 828 |
-
})
|
| 829 |
-
return {"items": items}
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
@app.post("/web/scrape", operation_id="scrape_public_url")
|
| 833 |
-
async def web_scrape(req: WebScrapeRequest):
|
| 834 |
-
parsed = urlparse(req.url)
|
| 835 |
-
if parsed.scheme not in {"http", "https"}:
|
| 836 |
-
raise HTTPException(status_code=400, detail="Only http and https URLs are allowed")
|
| 837 |
-
if private_or_internal_host(parsed.hostname or ""):
|
| 838 |
-
raise HTTPException(status_code=400, detail="Private/internal hosts are blocked")
|
| 839 |
-
|
| 840 |
-
async with httpx.AsyncClient(
|
| 841 |
-
follow_redirects=True,
|
| 842 |
-
timeout=15,
|
| 843 |
-
headers={"User-Agent": "SelfRelaunchingGPTKernel/4.0 public scraper"},
|
| 844 |
-
) as client:
|
| 845 |
-
resp = await client.get(req.url)
|
| 846 |
-
final_url = str(resp.url)
|
| 847 |
-
if private_or_internal_host(urlparse(final_url).hostname or ""):
|
| 848 |
-
raise HTTPException(status_code=400, detail="Redirected to private/internal host")
|
| 849 |
-
raw = resp.content[: req.max_bytes]
|
| 850 |
-
|
| 851 |
-
ctype = resp.headers.get("content-type", "")
|
| 852 |
-
extracted = extract_title_text(raw, ctype)
|
| 853 |
-
payload = {
|
| 854 |
-
"url": req.url,
|
| 855 |
-
"final_url": final_url,
|
| 856 |
-
"status_code": resp.status_code,
|
| 857 |
-
"content_type": ctype,
|
| 858 |
-
"headers": {k: v for k, v in resp.headers.items() if k.lower() in {"content-type", "content-length", "server", "last-modified", "etag"}},
|
| 859 |
-
"title": extracted["title"],
|
| 860 |
-
"text_preview": extracted["text"][: req.preview_chars],
|
| 861 |
-
"bytes_read": len(raw),
|
| 862 |
-
"sha256": sha256_bytes(raw),
|
| 863 |
-
"truncated": len(resp.content) > len(raw),
|
| 864 |
-
"saved_path": None,
|
| 865 |
}
|
| 866 |
|
| 867 |
-
if req.save:
|
| 868 |
-
fname = safe_name(parsed.netloc + parsed.path, "scrape") + ".json"
|
| 869 |
-
target = workspace_path(req.workspace, fname)
|
| 870 |
-
save_json(target, payload)
|
| 871 |
-
payload["saved_path"] = str(target.relative_to(workspace_path(req.workspace, ".")))
|
| 872 |
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
labels.append("artifact_or_receipt")
|
| 889 |
-
if any(x in lower for x in ["relaunch", "restart", "rebuild", "deploy"]):
|
| 890 |
-
labels.append("deployment_control")
|
| 891 |
-
|
| 892 |
-
risk_flags = []
|
| 893 |
-
if any(x in lower for x in ["api key", "secret", "private key", "password", "token"]):
|
| 894 |
-
risk_flags.append("credential_sensitive")
|
| 895 |
-
if any(x in lower for x in ["live order", "real money", "no stop loss", "guaranteed profit"]):
|
| 896 |
-
risk_flags.append("financial_risk")
|
| 897 |
-
if any(x in lower for x in ["unrestricted shell", "public shell", "no auth"]):
|
| 898 |
-
risk_flags.append("unsafe_public_execution")
|
| 899 |
-
|
| 900 |
-
payload = {
|
| 901 |
-
"labels": labels,
|
| 902 |
-
"risk_flags": risk_flags,
|
| 903 |
-
"recommended_route": labels[0] if labels else "general",
|
| 904 |
-
"request_sha256": sha256_bytes(text.encode("utf-8")),
|
| 905 |
-
"context": req.context,
|
| 906 |
-
}
|
| 907 |
-
return attach_receipt(payload, "request.analysis", summary=text[:240])
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
@app.get("/requests/recent", operation_id="recent_request_analysis")
|
| 911 |
-
def recent_requests(limit: int = 100):
|
| 912 |
-
limit = max(1, min(limit, 500))
|
| 913 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 914 |
-
rows = con.execute(
|
| 915 |
-
"SELECT id, method, path, query, client, user_agent, status_code, duration_ms, request_sha256, created_at FROM request_log ORDER BY created_at DESC LIMIT ?",
|
| 916 |
-
(limit,),
|
| 917 |
-
).fetchall()
|
| 918 |
-
return {"items": [
|
| 919 |
-
{"id": r[0], "method": r[1], "path": r[2], "query": r[3], "client": r[4], "user_agent": r[5], "status_code": r[6], "duration_ms": r[7], "request_sha256": r[8], "created_at": r[9]}
|
| 920 |
-
for r in rows
|
| 921 |
-
]}
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
@app.post("/tools/register", operation_id="register_tool")
|
| 925 |
-
def register_tool(req: ToolRegisterRequest, x_admin_token: Optional[str] = Header(default=None)):
|
| 926 |
-
require_admin(x_admin_token)
|
| 927 |
-
name = safe_name(req.name)
|
| 928 |
-
if req.mode not in {"command", "python"}:
|
| 929 |
-
raise HTTPException(status_code=400, detail="mode must be command or python")
|
| 930 |
-
if req.mode == "command" and req.template:
|
| 931 |
-
validate_command(render_template(req.template, {k: "x" for k in req.required_args}, req.required_args))
|
| 932 |
-
now = now_iso()
|
| 933 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 934 |
-
con.execute(
|
| 935 |
-
"""INSERT INTO tools(name, description, mode, template, input_schema_json, required_args_json, enabled, created_at, updated_at)
|
| 936 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 937 |
-
ON CONFLICT(name) DO UPDATE SET description=excluded.description, mode=excluded.mode, template=excluded.template,
|
| 938 |
-
input_schema_json=excluded.input_schema_json, required_args_json=excluded.required_args_json, enabled=excluded.enabled, updated_at=excluded.updated_at""",
|
| 939 |
-
(name, req.description, req.mode, req.template, json.dumps(req.input_schema), json.dumps(req.required_args), 1 if req.enabled else 0, now, now)
|
| 940 |
-
)
|
| 941 |
-
payload = {"name": name, "registered": True, "mode": req.mode}
|
| 942 |
-
return attach_receipt(payload, "tool.register", actor="admin", summary=name)
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
@app.get("/tools", operation_id="list_tools")
|
| 946 |
-
def list_tools():
|
| 947 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 948 |
-
rows = con.execute("SELECT name, description, mode, enabled, input_schema_json, required_args_json, created_at, updated_at FROM tools ORDER BY name").fetchall()
|
| 949 |
-
return {"tools": [
|
| 950 |
-
{"name": r[0], "description": r[1], "mode": r[2], "enabled": bool(r[3]), "input_schema": json.loads(r[4] or "{}"), "required_args": json.loads(r[5] or "[]"), "created_at": r[6], "updated_at": r[7]}
|
| 951 |
-
for r in rows
|
| 952 |
-
]}
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
@app.post("/tool/{name}", operation_id="invoke_tool")
|
| 956 |
-
async def invoke_tool(name: str, req: ToolInvokeRequest):
|
| 957 |
-
name = safe_name(name)
|
| 958 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 959 |
-
row = con.execute("SELECT mode, template, enabled, required_args_json FROM tools WHERE name = ?", (name,)).fetchone()
|
| 960 |
-
if not row:
|
| 961 |
-
raise HTTPException(status_code=404, detail="Tool not found")
|
| 962 |
-
mode, template, enabled, required_json = row
|
| 963 |
-
if not enabled:
|
| 964 |
-
raise HTTPException(status_code=403, detail="Tool disabled")
|
| 965 |
-
required = json.loads(required_json or "[]")
|
| 966 |
-
rendered = render_template(template, req.params, required)
|
| 967 |
-
if mode == "command":
|
| 968 |
-
return await terminal_run(TerminalRunRequest(workspace=req.workspace, command=rendered, timeout_seconds=req.timeout_seconds))
|
| 969 |
-
if mode == "python":
|
| 970 |
-
code = "ARGS = " + json.dumps(req.params, ensure_ascii=False) + "\n" + rendered
|
| 971 |
-
return await python_run(PythonRunRequest(workspace=req.workspace, code=code, timeout_seconds=req.timeout_seconds))
|
| 972 |
-
raise HTTPException(status_code=400, detail="Unknown tool mode")
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
@app.post("/sessions", operation_id="create_session")
|
| 976 |
-
def create_session(req: SessionCreateRequest, x_admin_token: Optional[str] = Header(default=None)):
|
| 977 |
-
require_admin(x_admin_token)
|
| 978 |
-
sid = f"sess_{uuid.uuid4().hex[:16]}"
|
| 979 |
-
cwd = workspace_path(req.workspace, req.cwd)
|
| 980 |
-
now = now_iso()
|
| 981 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 982 |
-
con.execute("INSERT INTO sessions(id, workspace, cwd, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", (sid, req.workspace, str(cwd), now, now))
|
| 983 |
-
return attach_receipt({"session_id": sid, "workspace": req.workspace, "cwd": str(cwd), "created_at": now}, "session.create", actor="admin", workspace=req.workspace, summary=sid)
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
@app.post("/sessions/run", operation_id="run_session")
|
| 987 |
-
async def run_session(req: SessionRunRequest, x_admin_token: Optional[str] = Header(default=None)):
|
| 988 |
-
require_admin(x_admin_token)
|
| 989 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 990 |
-
row = con.execute("SELECT workspace, cwd FROM sessions WHERE id = ?", (req.session_id,)).fetchone()
|
| 991 |
-
if not row:
|
| 992 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 993 |
-
workspace, cwd = row
|
| 994 |
-
rel = os.path.relpath(cwd, workspace_path(workspace, "."))
|
| 995 |
-
return await terminal_run(TerminalRunRequest(workspace=workspace, command=req.command, timeout_seconds=req.timeout_seconds, cwd=rel))
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
@app.get("/receipts", operation_id="list_receipts")
|
| 999 |
-
def list_receipts(kind: Optional[str] = None, q: Optional[str] = None, limit: int = 100):
|
| 1000 |
-
limit = max(1, min(limit, 1000))
|
| 1001 |
-
rows = []
|
| 1002 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 1003 |
-
if kind:
|
| 1004 |
-
data = con.execute("SELECT id, kind, actor, workspace, summary, payload_sha256, created_at FROM receipts WHERE kind = ? ORDER BY created_at DESC LIMIT ?", (kind, limit)).fetchall()
|
| 1005 |
-
else:
|
| 1006 |
-
data = con.execute("SELECT id, kind, actor, workspace, summary, payload_sha256, created_at FROM receipts ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
| 1007 |
-
for r in data:
|
| 1008 |
-
item = {"id": r[0], "kind": r[1], "actor": r[2], "workspace": r[3], "summary": r[4], "payload_sha256": r[5], "created_at": r[6]}
|
| 1009 |
-
if q and q.lower() not in json.dumps(item, ensure_ascii=False).lower():
|
| 1010 |
-
continue
|
| 1011 |
-
rows.append(item)
|
| 1012 |
-
return {"items": rows}
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
@app.get("/receipts/{receipt_id}", operation_id="get_receipt")
|
| 1016 |
-
def get_receipt(receipt_id: str):
|
| 1017 |
-
rid = safe_name(receipt_id)
|
| 1018 |
-
path = RECEIPTS_ROOT / f"{rid}.json"
|
| 1019 |
-
if path.exists():
|
| 1020 |
-
return json.loads(path.read_text(encoding="utf-8"))
|
| 1021 |
-
with sqlite3.connect(LEDGER_DB, timeout=5) as con:
|
| 1022 |
-
row = con.execute("SELECT id, kind, actor, workspace, summary, payload_json, payload_sha256, created_at FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
|
| 1023 |
-
if not row:
|
| 1024 |
-
raise HTTPException(status_code=404, detail="Receipt not found")
|
| 1025 |
-
return {"id": row[0], "kind": row[1], "actor": row[2], "workspace": row[3], "summary": row[4], "payload": json.loads(row[5]), "payload_sha256": row[6], "created_at": row[7]}
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
@app.get("/deployment/status", operation_id="deployment_status")
|
| 1029 |
-
def deployment_status():
|
| 1030 |
-
checks = {}
|
| 1031 |
-
for label, path in {"data": DATA_DIR, "workspace": WORKSPACE_ROOT, "artifacts": ARTIFACT_ROOT, "receipts": RECEIPTS_ROOT}.items():
|
| 1032 |
-
test = path / f".write_test_{uuid.uuid4().hex[:8]}"
|
| 1033 |
-
try:
|
| 1034 |
-
test.write_text("ok", encoding="utf-8")
|
| 1035 |
-
test.unlink(missing_ok=True)
|
| 1036 |
-
checks[label] = {"path": str(path), "writable": True}
|
| 1037 |
-
except Exception as e:
|
| 1038 |
-
checks[label] = {"path": str(path), "writable": False, "error": f"{type(e).__name__}: {str(e)}"}
|
| 1039 |
-
return {"public_base_url": PUBLIC_BASE_URL, "admin_token_enabled": bool(ADMIN_TOKEN), "relaunch_flag_exists": RELAUNCH_FLAG.exists(), "checks": checks}
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
@app.post("/admin/relaunch", operation_id="admin_relaunch")
|
| 1043 |
-
async def admin_relaunch(req: RelaunchRequest, x_admin_token: Optional[str] = Header(default=None)):
|
| 1044 |
-
require_admin(x_admin_token)
|
| 1045 |
-
payload = {"requested": True, "reason": req.reason, "delay_seconds": req.delay_seconds, "created_at": now_iso()}
|
| 1046 |
-
save_json(RELAUNCH_FLAG, payload)
|
| 1047 |
-
receipt = make_receipt("admin.relaunch", payload, actor="admin", summary=req.reason[:240])
|
| 1048 |
-
|
| 1049 |
-
async def delayed_exit():
|
| 1050 |
-
await asyncio.sleep(req.delay_seconds)
|
| 1051 |
-
os._exit(0)
|
| 1052 |
-
|
| 1053 |
-
asyncio.create_task(delayed_exit())
|
| 1054 |
-
return {"accepted": True, "message": "Relaunch requested. The process will exit; Hugging Face should restart the Space.", "receipt": receipt}
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
STRICT_OPENAPI_PATHS = [
|
| 1058 |
-
"/health",
|
| 1059 |
-
"/capabilities",
|
| 1060 |
-
"/terminal/run",
|
| 1061 |
-
"/python/run",
|
| 1062 |
-
"/files/write",
|
| 1063 |
-
"/files/read",
|
| 1064 |
-
"/files/list",
|
| 1065 |
-
"/workspace/tree",
|
| 1066 |
-
"/artifact/compile",
|
| 1067 |
-
"/url/issue",
|
| 1068 |
-
"/artifacts",
|
| 1069 |
-
"/web/scrape",
|
| 1070 |
-
"/analyze/request",
|
| 1071 |
-
"/requests/recent",
|
| 1072 |
-
"/tools/register",
|
| 1073 |
-
"/tools",
|
| 1074 |
-
"/tool/{name}",
|
| 1075 |
-
"/sessions",
|
| 1076 |
-
"/sessions/run",
|
| 1077 |
-
"/receipts",
|
| 1078 |
-
"/receipts/{receipt_id}",
|
| 1079 |
-
"/deployment/status",
|
| 1080 |
-
"/admin/relaunch",
|
| 1081 |
-
]
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
@app.get("/openapi.json", include_in_schema=False)
|
| 1085 |
-
def actions_openapi():
|
| 1086 |
-
raw = app.openapi()
|
| 1087 |
-
strict = {
|
| 1088 |
-
"openapi": "3.0.3",
|
| 1089 |
-
"info": raw.get("info", {}),
|
| 1090 |
-
"servers": [{"url": PUBLIC_BASE_URL}],
|
| 1091 |
-
"paths": {k: v for k, v in raw.get("paths", {}).items() if k in STRICT_OPENAPI_PATHS},
|
| 1092 |
-
"components": raw.get("components", {}),
|
| 1093 |
}
|
| 1094 |
-
|
| 1095 |
-
def scrub(obj):
|
| 1096 |
-
if isinstance(obj, dict):
|
| 1097 |
-
obj.pop("examples", None)
|
| 1098 |
-
obj.pop("const", None)
|
| 1099 |
-
for k in list(obj.keys()):
|
| 1100 |
-
scrub(obj[k])
|
| 1101 |
-
elif isinstance(obj, list):
|
| 1102 |
-
for x in obj:
|
| 1103 |
-
scrub(x)
|
| 1104 |
-
|
| 1105 |
-
scrub(strict)
|
| 1106 |
-
return JSONResponse(strict)
|
|
|
|
| 1 |
+
"""josephrw-endpoint HF Space — MasseurBoost API endpoint.
|
| 2 |
+
|
| 3 |
+
Exposes the SaaS app on Hugging Face Spaces with a lightweight
|
| 4 |
+
read-only mode (no local DB writes, no Selenium).
|
| 5 |
+
"""
|
| 6 |
import os
|
| 7 |
+
import sys
|
| 8 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import sqlite3
|
|
|
|
|
|
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
+
from fastapi import FastAPI, Request, Query
|
| 12 |
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
from fastapi.templating import Jinja2Templates
|
| 15 |
+
|
| 16 |
+
# ─── Config ──────────────────────────────────────────────────
|
| 17 |
+
# The DB is bundled in the Space repo under /data/masseurs.db
|
| 18 |
+
# Falls back to a small demo DB if not present.
|
| 19 |
+
DB_PATH = Path(os.environ.get("DB_PATH", "/data/masseurs.db"))
|
| 20 |
+
if not DB_PATH.exists():
|
| 21 |
+
DB_PATH = Path(__file__).parent / "data" / "masseurs.db"
|
| 22 |
+
|
| 23 |
+
BASE_DIR = Path(__file__).parent
|
| 24 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 25 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 26 |
+
|
| 27 |
+
app = FastAPI(title="MasseurBoost Endpoint", docs_url="/api/docs")
|
| 28 |
+
|
| 29 |
+
if TEMPLATES_DIR.exists():
|
| 30 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 31 |
+
if STATIC_DIR.exists():
|
| 32 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 33 |
+
|
| 34 |
+
RENTMASSEUR_BASE = "https://rentmasseur.com"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_db():
|
| 38 |
+
conn = sqlite3.connect(str(DB_PATH))
|
| 39 |
+
conn.row_factory = sqlite3.Row
|
| 40 |
+
return conn
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ─── Health ──────────────────────────────────────────────────
|
| 44 |
+
@app.get("/api/health")
|
| 45 |
+
async def health():
|
| 46 |
+
return {"status": "ok", "db": str(DB_PATH), "db_exists": DB_PATH.exists()}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ─── Market stats ────────────────────────────────────────────
|
| 50 |
+
@app.get("/api/market-stats")
|
| 51 |
+
async def market_stats():
|
| 52 |
+
if not DB_PATH.exists():
|
| 53 |
+
return {"error": "DB not found", "total_masseurs": 0}
|
| 54 |
+
conn = get_db()
|
| 55 |
+
c = conn.cursor()
|
| 56 |
+
total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
|
| 57 |
+
cities = c.execute("SELECT COUNT(DISTINCT city) FROM masseurs").fetchone()[0]
|
| 58 |
+
gold = c.execute("SELECT COUNT(*) FROM masseurs WHERE is_gold=1").fetchone()[0]
|
| 59 |
+
avg_vpd = c.execute("SELECT AVG(views_per_day) FROM masseurs WHERE views_per_day > 0").fetchone()[0]
|
| 60 |
+
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return {
|
| 62 |
+
"total_masseurs": total,
|
| 63 |
+
"cities_covered": cities,
|
| 64 |
+
"gold_members": gold,
|
| 65 |
+
"avg_views_per_day": round(avg_vpd or 0, 1),
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
|
| 69 |
+
# ─── Search masseurs (with profile URLs) ─────────────────────
|
| 70 |
+
@app.get("/api/search")
|
| 71 |
+
async def search_masseurs(
|
| 72 |
+
city: str = Query(""),
|
| 73 |
+
limit: int = Query(20, le=100),
|
| 74 |
+
offset: int = Query(0),
|
| 75 |
+
sort: str = Query("views_per_day"),
|
| 76 |
+
min_vpd: float = Query(0),
|
| 77 |
+
):
|
| 78 |
+
if not DB_PATH.exists():
|
| 79 |
+
return {"error": "DB not found", "total": 0, "results": []}
|
| 80 |
+
conn = get_db()
|
| 81 |
+
c = conn.cursor()
|
| 82 |
+
|
| 83 |
+
where = "WHERE views_per_day >= ?"
|
| 84 |
+
params = [min_vpd]
|
| 85 |
+
if city:
|
| 86 |
+
where += " AND city = ?"
|
| 87 |
+
params.append(city)
|
| 88 |
+
|
| 89 |
+
valid_sorts = {"views_per_day", "visits", "reviews_count", "rating", "bio"}
|
| 90 |
+
sort = sort if sort in valid_sorts else "views_per_day"
|
| 91 |
+
|
| 92 |
+
total = c.execute(f"SELECT COUNT(*) FROM masseurs {where}", params).fetchone()[0]
|
| 93 |
+
rows = c.execute(
|
| 94 |
+
f"SELECT username, city, views_per_day, visits, member_since, rating, reviews_count, is_gold, photo_count FROM masseurs {where} ORDER BY {sort} DESC LIMIT ? OFFSET ?",
|
| 95 |
+
params + [limit, offset],
|
| 96 |
+
).fetchall()
|
| 97 |
+
conn.close()
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
return {
|
| 100 |
+
"total": total,
|
| 101 |
+
"results": [
|
| 102 |
+
{
|
| 103 |
+
"username": r[0],
|
| 104 |
+
"city": r[1],
|
| 105 |
+
"vpd": r[2],
|
| 106 |
+
"visits": r[3],
|
| 107 |
+
"since": r[4],
|
| 108 |
+
"rating": r[5],
|
| 109 |
+
"reviews": r[6],
|
| 110 |
+
"gold": bool(r[7]),
|
| 111 |
+
"photos": r[8],
|
| 112 |
+
"profile_url": f"{RENTMASSEUR_BASE}/{r[0]}",
|
| 113 |
+
"internal_url": f"/profile/{r[0]}",
|
| 114 |
+
}
|
| 115 |
+
for r in rows
|
| 116 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
+
# ─── Analyze a profile ───────────────────────────────────────
|
| 121 |
+
@app.get("/api/analyze/{username}")
|
| 122 |
+
async def analyze_profile(username: str):
|
| 123 |
+
if not DB_PATH.exists():
|
| 124 |
+
return {"error": "DB not found"}
|
| 125 |
+
conn = get_db()
|
| 126 |
+
c = conn.cursor()
|
| 127 |
+
row = c.execute("SELECT * FROM masseurs WHERE username = ?", (username,)).fetchone()
|
| 128 |
+
if not row:
|
| 129 |
+
conn.close()
|
| 130 |
+
return {"error": "Profile not found", "username": username}
|
| 131 |
|
| 132 |
+
profile = dict(row)
|
| 133 |
+
total = c.execute("SELECT COUNT(*) FROM masseurs").fetchone()[0]
|
| 134 |
+
rank = c.execute("SELECT COUNT(*) FROM masseurs WHERE views_per_day > ?", (profile["views_per_day"],)).fetchone()[0] + 1
|
| 135 |
+
percentile = round((1 - rank / total) * 100, 1)
|
| 136 |
+
conn.close()
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return {
|
| 139 |
+
"username": username,
|
| 140 |
+
"rank": rank,
|
| 141 |
+
"total": total,
|
| 142 |
+
"percentile": percentile,
|
| 143 |
+
"views_per_day": profile["views_per_day"],
|
| 144 |
+
"total_visits": profile["visits"],
|
| 145 |
+
"member_since": profile["member_since"],
|
| 146 |
+
"rating": profile["rating"],
|
| 147 |
+
"reviews_count": profile["reviews_count"],
|
| 148 |
+
"is_gold": bool(profile["is_gold"]),
|
| 149 |
+
"photo_count": profile["photo_count"],
|
| 150 |
+
"bio_length": len(profile.get("bio", "") or ""),
|
| 151 |
+
"profile_url": f"{RENTMASSEUR_BASE}/{username}",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
# ─── Root ────────────────────────────────────────────────────
|
| 156 |
+
@app.get("/")
|
| 157 |
+
async def root():
|
| 158 |
+
return {
|
| 159 |
+
"service": "MasseurBoost Endpoint",
|
| 160 |
+
"spaces": [
|
| 161 |
+
"https://josephrw-endpoint.hf.space",
|
| 162 |
+
"https://josephrw-csc-engine.hf.space",
|
| 163 |
+
],
|
| 164 |
+
"endpoints": [
|
| 165 |
+
"/api/health",
|
| 166 |
+
"/api/market-stats",
|
| 167 |
+
"/api/search",
|
| 168 |
+
"/api/analyze/{username}",
|
| 169 |
+
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|