diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..fafa8ea --- /dev/null +++ b/api/.env.example @@ -0,0 +1,3 @@ +# 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 new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..841c4ed --- /dev/null +++ b/api/README.md @@ -0,0 +1,44 @@ +# 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 new file mode 100644 index 0000000..ecce524 --- /dev/null +++ b/api/itelescope_client.py @@ -0,0 +1,170 @@ +"""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"
Read-only view of the iTelescope.net member portal. Booking is not exposed here - it spends real points.
+ + + +