"""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}