From 9f09b12aebaae01cdb1028db755de06d4f7fe62f Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 24 Jul 2026 22:57:08 +0100 Subject: [PATCH] Move the API + console out to its own repo (laurence/itelescope-api) --- api/.env.example | 3 - api/.gitignore | 3 - api/README.md | 44 ---------- api/itelescope_client.py | 170 --------------------------------------- api/main.py | 108 ------------------------- api/requirements.txt | 4 - api/static/index.html | 160 ------------------------------------ 7 files changed, 492 deletions(-) delete mode 100644 api/.env.example delete mode 100644 api/.gitignore delete mode 100644 api/README.md delete mode 100644 api/itelescope_client.py delete mode 100644 api/main.py delete mode 100644 api/requirements.txt delete mode 100644 api/static/index.html diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index fafa8ea..0000000 --- a/api/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Copy to .env and fill in. Never commit the real .env. -ITELESCOPE_USERNAME=your_itelescope_username -ITELESCOPE_PASSWORD=your_itelescope_password diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index cff5543..0000000 --- a/api/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env -__pycache__/ -*.pyc diff --git a/api/README.md b/api/README.md deleted file mode 100644 index 841c4ed..0000000 --- a/api/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# iTelescope API + Console - -A small FastAPI wrapper over the iTelescope.net member portal, plus a web GUI on -top of it. Credentials come from a `.env` file - nothing is hard-coded. - -## What it exposes (all read-only) - -| Endpoint | What | -|---|---| -| `GET /api/health` | is it configured, known site codes | -| `GET /api/account` | points balance, plan, renewal, membership | -| `GET /api/plans` | per-scope access discount for the current plan | -| `GET /api/telescopes` | the telescope roster (from `../data/itelescope-telescopes.csv`) | -| `GET /api/weather` | safe/unsafe state for all six observatory sites | -| `GET /api/weather/{site}` | one site (SSO, UDRO, DSC, SRO, AC, EYE) | -| `GET /api/scope/{tid}/status` | reachability of a scope's own ACP control server | -| `GET /api/reservations` | legacy reservation grid + pointer to the live system | -| `GET /api/raw?path=...` | escape hatch: fetch any portal path (read-only) | - -The GUI dashboard is at `/`; interactive OpenAPI docs at `/docs`. - -## Run - -```bash -cd api -python -m pip install -r requirements.txt -cp .env.example .env # then edit .env with your username/password -uvicorn main:app --reload -# open http://127.0.0.1:8000/ -``` - -## Deliberate omissions - -- **Booking is not exposed.** Reservations spend real points, and the live - booking system (`lookup.itelescope.online`) is Cloudflare-protected and needs a - browser session. If booking is ever added it must sit behind an explicit, - confirmed action - see the drain campaign policy in `../CAMPAIGN.md`. -- Session cookies live only in memory for the running process. - -## Notes - -- Auth is the portal's WebForms login (viewstate POST) captured in - `itelescope_client.py`; the session re-authenticates automatically if it expires. -- `DataService.svc` methods are WCF (HTTP GET, JSON under a `d` key). diff --git a/api/itelescope_client.py b/api/itelescope_client.py deleted file mode 100644 index ecce524..0000000 --- a/api/itelescope_client.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Thin client for iTelescope.net's member portal (go.itelescope.net). - -Authenticates once with the WebForms login (username/password from the -environment), holds the session cookie, and exposes the portal's read features -as plain Python. Booking is deliberately NOT a convenience method here - it -spends real points and lives behind an explicit, guarded call. - -Env: ITELESCOPE_USERNAME, ITELESCOPE_PASSWORD (see .env.example). -""" -from __future__ import annotations -import os -import re -import html -import time -import threading -from typing import Any - -import requests - -BASE = "https://go.itelescope.net" -LOGIN = BASE + "/login.aspx?PreviousPage=%2fdefault.aspx" -# The six observatory site codes used by the weather pages. -SITES = ["SSO", "UDRO", "DSC", "SRO", "AC", "EYE"] - - -class ITelescopeError(RuntimeError): - pass - - -class NotConfigured(ITelescopeError): - pass - - -def _strip_html(s: str) -> str: - return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("", "").strip() - - -class ITelescopeClient: - """One authenticated session against the iTelescope portal. - - Thread-safe for the simple case: a lock serialises (re)login so concurrent - FastAPI requests share a single cookie jar. Re-authenticates automatically - if the session has expired (a portal call bounces to the login page). - """ - - def __init__(self, username: str | None = None, password: str | None = None, - timeout: int = 30): - self.username = username or os.environ.get("ITELESCOPE_USERNAME", "") - self.password = password or os.environ.get("ITELESCOPE_PASSWORD", "") - self.timeout = timeout - self._s = requests.Session() - self._s.headers["User-Agent"] = "itelescope-api/1.0" - self._authed = False - self._lock = threading.RLock() # reentrant: a requests.Session is not - # thread-safe, so all portal calls (and - # the login they may trigger) serialise. - - # -- auth --------------------------------------------------------------- - def _hidden(self, page_text: str, name: str) -> str: - m = re.search(r'name="' + re.escape(name) + r'"[^>]*value="([^"]*)"', page_text) - return m.group(1) if m else "" - - def login(self) -> None: - if not self.username or not self.password: - raise NotConfigured("ITELESCOPE_USERNAME / ITELESCOPE_PASSWORD not set") - with self._lock: - r = self._s.get(LOGIN, timeout=self.timeout) - form = { - "__EVENTTARGET": "", "__EVENTARGUMENT": "", "__LASTFOCUS": "", - "__VIEWSTATE": self._hidden(r.text, "__VIEWSTATE"), - "__VIEWSTATEGENERATOR": self._hidden(r.text, "__VIEWSTATEGENERATOR"), - "__EVENTVALIDATION": self._hidden(r.text, "__EVENTVALIDATION"), - "UsernameTextBox": self.username, "PasswordTextBox": self.password, - "LoginButton": "Login", - } - r2 = self._s.post(LOGIN, data=form, timeout=self.timeout, allow_redirects=True) - ok = ("login.aspx" not in r2.url.lower()) or ("logout" in r2.text.lower()) - if not ok: - raise ITelescopeError("login failed (check credentials)") - self._authed = True - - def _ensure(self) -> None: - if not self._authed: - self.login() - - def _get(self, path: str, **kw) -> requests.Response: - with self._lock: - self._ensure() - url = path if path.startswith("http") else BASE + "/" + path.lstrip("/") - r = self._s.get(url, timeout=self.timeout, **kw) - if "login.aspx" in r.url.lower(): # session expired -> re-auth once - self._authed = False - self.login() - r = self._s.get(url, timeout=self.timeout, **kw) - return r - - def _svc(self, method: str) -> Any: - """Call a DataService.svc method (WCF, HTTP GET, JSON reply under 'd').""" - r = self._get(f"DataService.svc/{method}") - if r.status_code != 200: - raise ITelescopeError(f"{method} -> HTTP {r.status_code}") - try: - return r.json().get("d", r.json()) - except ValueError: - raise ITelescopeError(f"{method} returned non-JSON") - - # -- read features ------------------------------------------------------ - def account_status(self) -> dict: - d = self._svc("GetAccountStatus") - clean = {k: _strip_html(v) if isinstance(v, str) else v - for k, v in d.items() if not k.startswith("__")} - # pull the numeric balance out of e.g. "2001 points" - bal = clean.get("Balance", "") - m = re.search(r"([\d,]+)", bal) - clean["BalancePoints"] = int(m.group(1).replace(",", "")) if m else None - return clean - - def available_plans(self) -> Any: - """Per-scope access discount for the current membership plan.""" - return self._svc("GetMyAvailablePlans") - - def weather(self, site: str) -> dict: - site = site.upper() - if site not in SITES: - raise ITelescopeError(f"unknown site {site!r}; one of {SITES}") - r = self._get(f"Weather/Weather.aspx?site={site}") - text = _strip_html(r.text) - # surface any obvious "safe/unsafe/open/closed" state words - state = None - for w in ("UNSAFE", "SAFE", "OPEN", "CLOSED", "ROOF"): - if re.search(rf"\b{w}\b", r.text, re.I): - state = w.lower(); break - return {"site": site, "state": state, "http": r.status_code, - "excerpt": text[:600]} - - def scope_status(self, tid: str) -> dict: - """Best-effort reach of a telescope's own ACP control web server.""" - n = re.sub(r"\D", "", tid) - out = {"telescope": f"T{n}", "reachable": False, "url": None, "http": None} - for url in (f"https://t{n}.itelescope.online/", f"http://t{n}.itelescope.net:80{n}/"): - try: - with self._lock: - r = self._s.get(url, auth=(self.username, self.password), timeout=15) - out.update(reachable=r.status_code < 500, url=url, http=r.status_code) - if r.ok: - break - except requests.RequestException: - continue - return out - - def reservations(self) -> dict: - """Current reservations. The live booking system moved to the - Cloudflare-protected lookup.itelescope.online, which needs a browser, so - this returns the legacy grid when present and flags the new system.""" - r = self._get("Reservation/Default.aspx") - rows = [] - for tr in re.findall(r"]*>(.*?)", r.text, re.S): - cells = [_strip_html(c) for c in re.findall(r"]*>(.*?)", tr, re.S)] - cells = [c for c in cells if c] - if cells and re.search(r"20\d\d|T\d\d|GRAS", " ".join(cells)): - rows.append(cells) - return {"legacy_rows": rows, - "note": "Live reservations are managed at lookup.itelescope.online " - "(Cloudflare-protected; a browser session is required to read/write them).", - "count": len(rows)} - - def raw(self, path: str) -> dict: - """Escape hatch: fetch any portal path (read-only) for exploring.""" - r = self._get(path) - return {"url": r.url, "http": r.status_code, "excerpt": _strip_html(r.text)[:1500]} diff --git a/api/main.py b/api/main.py deleted file mode 100644 index 0e43d5d..0000000 --- a/api/main.py +++ /dev/null @@ -1,108 +0,0 @@ -"""FastAPI wrapper over the iTelescope member portal. - -Run: uvicorn main:app --reload (from this directory, with .env present) -GUI: http://127.0.0.1:8000/ (dashboard) -Docs: http://127.0.0.1:8000/docs (OpenAPI) - -Credentials come from the environment / a .env file - never hard-coded. Every -endpoint here is READ-ONLY. Booking spends real points and is intentionally not -exposed; add it deliberately behind its own confirmation if ever needed. -""" -from __future__ import annotations -import os -from functools import lru_cache -from pathlib import Path - -from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles - -from itelescope_client import ITelescopeClient, ITelescopeError, NotConfigured, SITES - -load_dotenv() -HERE = Path(__file__).parent -app = FastAPI(title="iTelescope API", version="1.0", - description="Read-only wrapper over the iTelescope.net member portal.") - - -@lru_cache(maxsize=1) -def client() -> ITelescopeClient: - return ITelescopeClient() - - -def _guard(fn): - try: - return fn() - except NotConfigured as e: - raise HTTPException(503, str(e)) - except ITelescopeError as e: - raise HTTPException(502, str(e)) - - -@app.get("/api/health") -def health(): - c = client() - return {"configured": bool(c.username and c.password), "sites": SITES} - - -@app.get("/api/account") -def account(): - return _guard(lambda: client().account_status()) - - -@app.get("/api/plans") -def plans(): - return _guard(lambda: client().available_plans()) - - -@app.get("/api/telescopes") -def telescopes(): - """The telescope roster from the repo's snapshotted spec sheet.""" - csv = HERE.parent / "data" / "itelescope-telescopes.csv" - if not csv.exists(): - raise HTTPException(404, "telescope csv not found") - import csv as _csv - with open(csv, encoding="utf-8-sig", newline="") as fh: - return list(_csv.DictReader(fh)) - - -@app.get("/api/weather/{site}") -def weather(site: str): - return _guard(lambda: client().weather(site)) - - -@app.get("/api/weather") -def weather_all(): - c = client() - out = {} - for s in SITES: - try: - out[s] = c.weather(s) - except ITelescopeError as e: - out[s] = {"site": s, "error": str(e)} - return out - - -@app.get("/api/scope/{tid}/status") -def scope_status(tid: str): - return _guard(lambda: client().scope_status(tid)) - - -@app.get("/api/reservations") -def reservations(): - return _guard(lambda: client().reservations()) - - -@app.get("/api/raw") -def raw(path: str): - return _guard(lambda: client().raw(path)) - - -# --- GUI ------------------------------------------------------------------ -app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") - - -@app.get("/") -def index(): - return FileResponse(HERE / "static" / "index.html") diff --git a/api/requirements.txt b/api/requirements.txt deleted file mode 100644 index 09a23b1..0000000 --- a/api/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi>=0.110 -uvicorn[standard]>=0.27 -requests>=2.31 -python-dotenv>=1.0 diff --git a/api/static/index.html b/api/static/index.html deleted file mode 100644 index 9af54a0..0000000 --- a/api/static/index.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - -iTelescope Console - - - -
-
- iTelescope

Console

- -
-

Read-only view of the iTelescope.net member portal. Booking is not exposed here - it spends real points.

- -
- -
-

Weather - all sites

-
loading...
-
- -
-

Reservations

-
loading...
-
- -
-

Telescope status check

-
- - - -
-
- -
-

Telescope roster

-
loading...
-
- - -
- - - - -- 2.49.1