josephrw commited on
Commit
29d6fe1
·
verified ·
1 Parent(s): 8ab5f2e

Deploy MasseurBoost Endpoint API

Browse files
Files changed (4) hide show
  1. Dockerfile +5 -18
  2. README.md +6 -8
  3. __pycache__/app.cpython-313.pyc +0 -0
  4. 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 apt-get update && apt-get install -y --no-install-recommends \
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 app.py /app/app.py
22
 
23
- RUN mkdir -p /data/workspace /data/ledger
 
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
- title: Endpoint
3
- emoji:
4
- colorFrom: yellow
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 re
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 datetime import datetime, timezone
15
- from typing import Any, Dict, List, Optional
16
- from urllib.parse import urlparse
17
-
18
- import httpx
19
- from fastapi import FastAPI, HTTPException, Header, Request
20
- from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse, JSONResponse
21
- from fastapi.middleware.cors import CORSMiddleware
22
- from pydantic import BaseModel, Field
23
-
24
-
25
- PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "https://josephrw-endpoint.hf.space").rstrip("/")
26
- ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "").strip()
27
- APP_NAME = "Self-Relaunching GPT Kernel"
28
-
29
- DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
30
- WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", str(DATA_DIR / "workspaces"))).resolve()
31
- ARTIFACT_ROOT = Path(os.getenv("ARTIFACT_ROOT", str(DATA_DIR / "artifacts"))).resolve()
32
- RECEIPTS_ROOT = Path(os.getenv("RECEIPTS_ROOT", str(DATA_DIR / "receipts"))).resolve()
33
- LEDGER_DB = Path(os.getenv("LEDGER_DB", str(DATA_DIR / "ledger.sqlite3"))).resolve()
34
- TOOLS_FILE = Path(os.getenv("TOOLS_FILE", str(DATA_DIR / "tools.json"))).resolve()
35
- RELAUNCH_FLAG = Path(os.getenv("RELAUNCH_FLAG", str(DATA_DIR / "relaunch_requested.json"))).resolve()
36
-
37
- MAX_TIMEOUT_SECONDS = int(os.getenv("MAX_TIMEOUT_SECONDS", "30"))
38
- MAX_OUTPUT_BYTES = int(os.getenv("MAX_OUTPUT_BYTES", "120000"))
39
- MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", "20000000"))
40
- SCRAPE_MAX_BYTES = int(os.getenv("SCRAPE_MAX_BYTES", "1500000"))
41
- SCRAPE_PREVIEW_CHARS = int(os.getenv("SCRAPE_PREVIEW_CHARS", "18000"))
42
-
43
- for p in [DATA_DIR, WORKSPACE_ROOT, ARTIFACT_ROOT, RECEIPTS_ROOT, LEDGER_DB.parent, TOOLS_FILE.parent]:
44
- p.mkdir(parents=True, exist_ok=True)
45
-
46
- app = FastAPI(
47
- title=APP_NAME,
48
- version="4.0.0",
49
- description=(
50
- "GPT Actions compatible self-relaunching kernel with controlled terminal, Python execution, "
51
- "public web scraping, file saving, receipts, request analysis, persistent tools, artifacts, "
52
- "and admin-protected relaunch."
53
- ),
54
- openapi_url="/openapi_raw.json",
55
- docs_url="/docs",
56
- )
57
-
58
- app.add_middleware(
59
- CORSMiddleware,
60
- allow_origins=["*"],
61
- allow_credentials=False,
62
- allow_methods=["*"],
63
- allow_headers=["*"],
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
- "ok": True,
595
- "app": APP_NAME,
596
- "version": "4.0.0",
597
- "public_base_url": PUBLIC_BASE_URL,
598
- "openapi_url": f"{PUBLIC_BASE_URL}/openapi.json",
599
- "admin_token_enabled": bool(ADMIN_TOKEN),
600
- "time": now_iso(),
601
  }
602
 
603
 
604
- class SpeakRequest(BaseModel):
605
- speaking: bool
606
-
607
-
608
- _SPEAKING = False
609
-
610
-
611
- @app.post("/speak", operation_id="speak_toggle")
612
- def speak_toggle(req: SpeakRequest):
613
- global _SPEAKING
614
- _SPEAKING = req.speaking
615
- return {"speaking": _SPEAKING, "state": "paused" if _SPEAKING else "morphing"}
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
- "kernel": APP_NAME,
627
- "version": "4.0.0",
628
- "features": [
629
- "admin_protected_self_relaunch",
630
- "controlled_terminal",
631
- "python_execution",
632
- "public_web_scrape_with_private_ip_block",
633
- "file_save_read_list",
634
- "artifact_url_issue",
635
- "persistent_json_receipts",
636
- "sqlite_ledger",
637
- "request_analysis_log",
638
- "persistent_parameterized_tools",
639
- "sessions",
 
 
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
- @app.post("/files/write", operation_id="write_file")
714
- def files_write(req: FileWriteRequest):
715
- target = workspace_path(req.workspace, req.path)
716
- target.parent.mkdir(parents=True, exist_ok=True)
717
- raw = req.content.encode(req.encoding)
718
- if len(raw) > MAX_FILE_BYTES:
719
- raise HTTPException(status_code=413, detail="File too large")
720
- target.write_bytes(raw)
721
- payload = {"workspace": req.workspace, "path": req.path, "bytes": len(raw), "sha256": sha256_bytes(raw)}
722
- return attach_receipt(payload, "files.write", workspace=req.workspace, summary=req.path)
 
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
- "workspace": req.workspace,
734
- "path": req.path,
735
- "bytes": len(raw),
736
- "sha256": sha256_bytes(raw_all),
737
- "content": raw.decode("utf-8", errors="replace"),
738
- "truncated": len(raw_all) > req.max_bytes,
739
- }
740
-
741
-
742
- @app.post("/files/list", operation_id="list_files")
743
- def files_list(req: FileListRequest):
744
- base = workspace_path(req.workspace, req.path)
745
- if not base.exists():
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
- return attach_receipt(payload, "web.scrape", workspace=req.workspace, summary=req.url[:240])
874
-
875
-
876
- @app.post("/analyze/request", operation_id="analyze_gpt_request")
877
- def analyze_request(req: AnalyzeRequest):
878
- text = req.request_text
879
- lower = text.lower()
880
- labels = []
881
- if any(x in lower for x in ["trade", "trading", "underwrite", "market", "perp", "futures"]):
882
- labels.append("trading_or_underwriting")
883
- if any(x in lower for x in ["terminal", "run", "execute", "shell", "command"]):
884
- labels.append("terminal_execution")
885
- if any(x in lower for x in ["scrape", "crawl", "website", "url", "fetch"]):
886
- labels.append("web_retrieval")
887
- if any(x in lower for x in ["file", "save", "artifact", "receipt", "ledger"]):
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
  }