"""Hollo 0.9 (Fedify): one login (made by peers/hollo.sh) owning every town account, each its own actor, the way a PrivaPub root owns its personas. Accounts are made through its /accounts form and tokens through its OAuth consent page, which asks the signed-in owner which account the token is for. Its forms check only that Origin is its own (Hono's csrf()).""" import html import re import urllib.parse from core import podman from dialects.base import Stored from dialects.mastodon_api import MastodonApi OOB = "urn:ietf:wg:oauth:2.0:oob" OWNER = ("owner@hollo.test", "Hollo-Pasture-Pass-1") VIS = {"public": "public", "unlisted": "unlisted", "private": "followers", "direct": "direct"} class Hollo(MastodonApi): platform = "hollo" caps = MastodonApi.caps | {"quote", "react"} def __init__(self, host): super().__init__(host) self._cookies = {} def _form(self, method, path, form=None): headers = {"Accept": "text/html,*/*", "Origin": self.base} if self._cookies: headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self._cookies.items()) r = self.http.request(method, self.base + path, headers=headers, form=form) for k, v in r.headers: if k.lower() == "set-cookie": name, _, value = v.split(";")[0].partition("=") self._cookies[name.strip()] = value.strip() return r def _login(self): if "login" not in self._cookies: self._form("POST", "/login", {"email": OWNER[0], "password": OWNER[1]}) def provision(self, accounts): self._login() existing = {r["handle"].split("@")[1] for r in podman.psql("hollo", "select handle from accounts where handle like '@%'") if r["handle"].count("@") >= 2} for a in accounts: if a.username in existing: continue self._form("POST", "/accounts", {"username": a.username, "name": a.name or a.username, "bio": a.bio or "", "discoverable": "on", "language": a.lang or "en", "visibility": "public", "themeColor": "azure", **({"protected": "on"} if a.locked else {})}) app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={ "client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json() return [self.session_from_token(a, self._token(app, a.username)) for a in accounts] def _token(self, app, username): query = urllib.parse.urlencode({"client_id": app["client_id"], "redirect_uri": OOB, "response_type": "code", "scope": "read write follow"}) page = self._form("GET", f"/oauth/authorize?{query}").text form = dict((k, html.unescape(v)) for k, v in re.findall(r']*>(.*?)', page, re.S): if f"@{username}@" in block: found = re.search(r'name="account_id"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="account_id"', block) if found: account = found.group(1) or found.group(2) if account is None: raise RuntimeError(f"Hollo's consent page offers no account @{username}") form.update({"account_id": account, "decision": "allow"}) r = self._form("POST", "/oauth/authorize", form) location = r.header("Location") or "" code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0] if code is None: found = re.search(r']*>([^<]+)', r.text) code = found.group(1).strip() if found else None if code is None: raise RuntimeError(f"Hollo gave @{username} no authorization code ({r.status})") return self.http.post(self.base + "/oauth/token", ok={200}, form={ "grant_type": "authorization_code", "code": code, "client_id": app["client_id"], "client_secret": app["client_secret"], "redirect_uri": OOB}).json()["access_token"] def update_profile(self, s, account): # Hollo has no update_credentials for fields and images beyond its own forms; name, bio and lock are enough here form = {"display_name": account.name, "note": account.bio, "locked": account.locked} self.api(s, "PATCH", "/api/v1/accounts/update_credentials", ok={200}, form=form) def post(self, s, spec): """Hollo reads a status as JSON (its poll is a nested object a form cannot carry).""" from dialects.base import Made, Unsupported if spec.kind not in ("note", "image", "video", "audio"): raise Unsupported(self.platform, f"post a {spec.kind}") form = self.status_form(s, spec) body = {k: v for k, v in form.items() if not k.startswith("poll[")} if spec.poll: body["poll"] = {"options": spec.poll["options"], "expires_in": spec.poll.get("expires_in", 86400), "multiple": bool(spec.poll.get("multiple"))} status = self.api_json(s, "POST", "/api/v1/statuses", json=body) return Made(status["uri"], status["id"], status.get("url")) def vote(self, s, uri, choices): sid = self.local_status_id(s, uri) or self.resolve(s, uri) poll = (self.api_json(s, "GET", f"/api/v1/statuses/{sid}").get("poll") or {}) if not poll: raise LookupError(f"{self.host} shows no poll on {uri}") self.api(s, "POST", f"/api/v1/polls/{poll['id']}/votes", ok={200}, json={"choices": choices}) def react(self, s, uri, emoji): sid = self.local_status_id(s, uri) or self.resolve(s, uri) self.api(s, "PUT", f"/api/v1/statuses/{sid}/emoji_reactions/{urllib.parse.quote(emoji)}", ok={200}) def _rows(self, uris): if not uris: return {} rows = podman.psql("hollo", """ select p.id::text as local_id, p.iri as uri, p.visibility::text as visibility, p.content_html as text, p.summary as cw, p.updated > p.published as edited, r.iri as parent_uri, (select count(*) from likes l where l.post_id = p.id) as likes, p.shares_count as boosts, p.replies_count as replies, (select array_agg(o.votes_count order by o.index) from poll_options o where o.poll_id = p.poll_id) as votes, (select json_object_agg(e.emoji, e.n) from (select emoji, count(*) n from reactions x where x.post_id = p.id group by emoji) e) as reactions from posts p left join posts r on r.id = p.reply_target_id where p.sharing_id is null and p.iri = any(string_to_array(:'p1', ' '))""", " ".join(uris)) return {r["uri"]: Stored(True, False, r["local_id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"], bool(r["edited"]), r["parent_uri"], r["likes"], r["boosts"], r["replies"], r["votes"], r["reactions"] or {}, r) for r in rows}